Compare commits

...

9 Commits

Author SHA1 Message Date
binwiederhier
f4c285f7ce WIP Busy timeout 2025-07-27 09:33:24 +02:00
binwiederhier
141ddb3a51 Comments 2025-07-26 12:20:11 +02:00
binwiederhier
f99801a2e6 Add "ntfy user hash" 2025-07-26 12:14:21 +02:00
binwiederhier
4457e9e26f Migration 2025-07-26 11:16:33 +02:00
binwiederhier
f59df0f40a Works 2025-07-21 17:44:00 +02:00
binwiederhier
51af114b2e Merge branch 'main' of github.com:binwiederhier/ntfy into predefined-users 2025-07-21 11:57:14 +02:00
Philipp C. Heckel
83bf9d4d6c Merge pull request #1390 from binwiederhier/template-dir
Advanced message templating: Sprig functions, pre-defined templates, custom templates via `template-dir`
2025-07-21 11:56:28 +02:00
binwiederhier
c0b5151bae Predefined users 2025-07-10 20:50:29 +02:00
binwiederhier
efef587671 WIP: Predefined users 2025-07-07 22:36:01 +02:00
16 changed files with 752 additions and 153 deletions

View File

@@ -232,7 +232,7 @@ cli-deps-update:
go get -u
go install honnef.co/go/tools/cmd/staticcheck@latest
go install golang.org/x/lint/golint@latest
go install github.com/goreleaser/goreleaser@latest
go install github.com/goreleaser/goreleaser/v2@latest
cli-build-results:
cat dist/config.yaml

View File

@@ -105,8 +105,10 @@ func changeAccess(c *cli.Context, manager *user.Manager, username string, topic
return err
}
u, err := manager.User(username)
if err == user.ErrUserNotFound {
if errors.Is(err, user.ErrUserNotFound) {
return fmt.Errorf("user %s does not exist", username)
} else if err != nil {
return err
} else if u.Role == user.RoleAdmin {
return fmt.Errorf("user %s is an admin user, access control entries have no effect", username)
}
@@ -175,7 +177,7 @@ func showAllAccess(c *cli.Context, manager *user.Manager) error {
func showUserAccess(c *cli.Context, manager *user.Manager, username string) error {
users, err := manager.User(username)
if err == user.ErrUserNotFound {
if errors.Is(err, user.ErrUserNotFound) {
return fmt.Errorf("user %s does not exist", username)
} else if err != nil {
return err
@@ -193,19 +195,27 @@ func showUsers(c *cli.Context, manager *user.Manager, users []*user.User) error
if u.Tier != nil {
tier = u.Tier.Name
}
fmt.Fprintf(c.App.ErrWriter, "user %s (role: %s, tier: %s)\n", u.Name, u.Role, tier)
provisioned := ""
if u.Provisioned {
provisioned = ", provisioned user"
}
fmt.Fprintf(c.App.ErrWriter, "user %s (role: %s, tier: %s%s)\n", u.Name, u.Role, tier, provisioned)
if u.Role == user.RoleAdmin {
fmt.Fprintf(c.App.ErrWriter, "- read-write access to all topics (admin role)\n")
} else if len(grants) > 0 {
for _, grant := range grants {
if grant.Allow.IsReadWrite() {
fmt.Fprintf(c.App.ErrWriter, "- read-write access to topic %s\n", grant.TopicPattern)
} else if grant.Allow.IsRead() {
fmt.Fprintf(c.App.ErrWriter, "- read-only access to topic %s\n", grant.TopicPattern)
} else if grant.Allow.IsWrite() {
fmt.Fprintf(c.App.ErrWriter, "- write-only access to topic %s\n", grant.TopicPattern)
grantProvisioned := ""
if grant.Provisioned {
grantProvisioned = ", provisioned access entry"
}
if grant.Permission.IsReadWrite() {
fmt.Fprintf(c.App.ErrWriter, "- read-write access to topic %s%s\n", grant.TopicPattern, grantProvisioned)
} else if grant.Permission.IsRead() {
fmt.Fprintf(c.App.ErrWriter, "- read-only access to topic %s%s\n", grant.TopicPattern, grantProvisioned)
} else if grant.Permission.IsWrite() {
fmt.Fprintf(c.App.ErrWriter, "- write-only access to topic %s%s\n", grant.TopicPattern, grantProvisioned)
} else {
fmt.Fprintf(c.App.ErrWriter, "- no access to topic %s\n", grant.TopicPattern)
fmt.Fprintf(c.App.ErrWriter, "- no access to topic %s%s\n", grant.TopicPattern, grantProvisioned)
}
}
} else {

View File

@@ -48,6 +48,8 @@ var flagsServe = append(
altsrc.NewStringFlag(&cli.StringFlag{Name: "auth-file", Aliases: []string{"auth_file", "H"}, EnvVars: []string{"NTFY_AUTH_FILE"}, Usage: "auth database file used for access control"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "auth-startup-queries", Aliases: []string{"auth_startup_queries"}, EnvVars: []string{"NTFY_AUTH_STARTUP_QUERIES"}, Usage: "queries run when the auth database is initialized"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "auth-default-access", Aliases: []string{"auth_default_access", "p"}, EnvVars: []string{"NTFY_AUTH_DEFAULT_ACCESS"}, Value: "read-write", Usage: "default permissions if no matching entries in the auth database are found"}),
altsrc.NewStringSliceFlag(&cli.StringSliceFlag{Name: "auth-provision-users", Aliases: []string{"auth_provision_users"}, EnvVars: []string{"NTFY_AUTH_PROVISION_USERS"}, Usage: "pre-provisioned declarative users"}),
altsrc.NewStringSliceFlag(&cli.StringSliceFlag{Name: "auth-provision-access", Aliases: []string{"auth_provision_access"}, EnvVars: []string{"NTFY_AUTH_PROVISION_ACCESS"}, Usage: "pre-provisioned declarative access control entries"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "attachment-cache-dir", Aliases: []string{"attachment_cache_dir"}, EnvVars: []string{"NTFY_ATTACHMENT_CACHE_DIR"}, Usage: "cache directory for attached files"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "attachment-total-size-limit", Aliases: []string{"attachment_total_size_limit", "A"}, EnvVars: []string{"NTFY_ATTACHMENT_TOTAL_SIZE_LIMIT"}, Value: util.FormatSize(server.DefaultAttachmentTotalSizeLimit), Usage: "limit of the on-disk attachment cache"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "attachment-file-size-limit", Aliases: []string{"attachment_file_size_limit", "Y"}, EnvVars: []string{"NTFY_ATTACHMENT_FILE_SIZE_LIMIT"}, Value: util.FormatSize(server.DefaultAttachmentFileSizeLimit), Usage: "per-file attachment size limit (e.g. 300k, 2M, 100M)"}),
@@ -154,6 +156,8 @@ func execServe(c *cli.Context) error {
authFile := c.String("auth-file")
authStartupQueries := c.String("auth-startup-queries")
authDefaultAccess := c.String("auth-default-access")
authProvisionUsersRaw := c.StringSlice("auth-provision-users")
authProvisionAccessRaw := c.StringSlice("auth-provision-access")
attachmentCacheDir := c.String("attachment-cache-dir")
attachmentTotalSizeLimitStr := c.String("attachment-total-size-limit")
attachmentFileSizeLimitStr := c.String("attachment-file-size-limit")
@@ -344,11 +348,19 @@ func execServe(c *cli.Context) error {
webRoot = "/" + webRoot
}
// Default auth permissions
// Convert default auth permission, read provisioned users
authDefault, err := user.ParsePermission(authDefaultAccess)
if err != nil {
return errors.New("if set, auth-default-access must start set to 'read-write', 'read-only', 'write-only' or 'deny-all'")
}
authProvisionUsers, err := parseProvisionUsers(authProvisionUsersRaw)
if err != nil {
return err
}
authProvisionAccess, err := parseProvisionAccess(authProvisionUsers, authProvisionAccessRaw)
if err != nil {
return err
}
// Special case: Unset default
if listenHTTP == "-" {
@@ -404,6 +416,8 @@ func execServe(c *cli.Context) error {
conf.AuthFile = authFile
conf.AuthStartupQueries = authStartupQueries
conf.AuthDefault = authDefault
conf.AuthProvisionedUsers = authProvisionUsers
conf.AuthProvisionedAccess = authProvisionAccess
conf.AttachmentCacheDir = attachmentCacheDir
conf.AttachmentTotalSizeLimit = attachmentTotalSizeLimit
conf.AttachmentFileSizeLimit = attachmentFileSizeLimit
@@ -517,6 +531,76 @@ func parseIPHostPrefix(host string) (prefixes []netip.Prefix, err error) {
return
}
func parseProvisionUsers(usersRaw []string) ([]*user.User, error) {
provisionUsers := make([]*user.User, 0)
for _, userLine := range usersRaw {
parts := strings.Split(userLine, ":")
if len(parts) != 3 {
return nil, fmt.Errorf("invalid auth-provision-users: %s, expected format: 'name:hash:role'", userLine)
}
username := strings.TrimSpace(parts[0])
passwordHash := strings.TrimSpace(parts[1])
role := user.Role(strings.TrimSpace(parts[2]))
if !user.AllowedUsername(username) {
return nil, fmt.Errorf("invalid auth-provision-users: %s, username invalid", userLine)
} else if err := user.AllowedPasswordHash(passwordHash); err != nil {
return nil, fmt.Errorf("invalid auth-provision-users: %s, %s", userLine, err.Error())
} else if !user.AllowedRole(role) {
return nil, fmt.Errorf("invalid auth-provision-users: %s, role %s is not allowed, allowed roles are 'admin' or 'user'", userLine, role)
}
provisionUsers = append(provisionUsers, &user.User{
Name: username,
Hash: passwordHash,
Role: role,
Provisioned: true,
})
}
return provisionUsers, nil
}
func parseProvisionAccess(provisionUsers []*user.User, provisionAccessRaw []string) (map[string][]*user.Grant, error) {
access := make(map[string][]*user.Grant)
for _, accessLine := range provisionAccessRaw {
parts := strings.Split(accessLine, ":")
if len(parts) != 3 {
return nil, fmt.Errorf("invalid auth-provision-access: %s, expected format: 'user:topic:permission'", accessLine)
}
username := strings.TrimSpace(parts[0])
if username == userEveryone {
username = user.Everyone
}
provisionUser, exists := util.Find(provisionUsers, func(u *user.User) bool {
return u.Name == username
})
if username != user.Everyone {
if !exists {
return nil, fmt.Errorf("invalid auth-provision-access: %s, user %s is not provisioned", accessLine, username)
} else if !user.AllowedUsername(username) {
return nil, fmt.Errorf("invalid auth-provision-access: %s, username %s invalid", accessLine, username)
} else if provisionUser.Role != user.RoleUser {
return nil, fmt.Errorf("invalid auth-provision-access: %s, user %s is not a regular user, only regular users can have ACL entries", accessLine, username)
}
}
topic := strings.TrimSpace(parts[1])
if !user.AllowedTopicPattern(topic) {
return nil, fmt.Errorf("invalid auth-provision-access: %s, topic pattern %s invalid", accessLine, topic)
}
permission, err := user.ParsePermission(strings.TrimSpace(parts[2]))
if err != nil {
return nil, fmt.Errorf("invalid auth-provision-access: %s, permission %s invalid, %s", accessLine, parts[2], err.Error())
}
if _, exists := access[username]; !exists {
access[username] = make([]*user.Grant, 0)
}
access[username] = append(access[username], &user.Grant{
TopicPattern: topic,
Permission: permission,
Provisioned: true,
})
}
return access, nil
}
func reloadLogLevel(inputSource altsrc.InputSourceContext) error {
newLevelStr, err := inputSource.String("log-level")
if err != nil {

View File

@@ -95,7 +95,6 @@ Example:
You may set the NTFY_PASSWORD environment variable to pass the new password or NTFY_PASSWORD_HASH to pass
directly the bcrypt hash. This is useful if you are updating users via scripts.
`,
},
{
@@ -134,6 +133,22 @@ as messages per day, attachment file sizes, etc.
Example:
ntfy user change-tier phil pro # Change tier to "pro" for user "phil"
ntfy user change-tier phil - # Remove tier from user "phil" entirely
`,
},
{
Name: "hash",
Usage: "Create password hash for a predefined user",
UsageText: "ntfy user hash",
Action: execUserHash,
Description: `Asks for a password and creates a bcrypt password hash.
This command is useful to create a password hash for a user, which can then be used
for predefined users in the server config file, in auth-provision-users.
Example:
$ ntfy user hash
(asks for password and confirmation)
$2a$10$YLiO8U21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C
`,
},
{
@@ -226,7 +241,7 @@ func execUserDel(c *cli.Context) error {
if err != nil {
return err
}
if _, err := manager.User(username); err == user.ErrUserNotFound {
if _, err := manager.User(username); errors.Is(err, user.ErrUserNotFound) {
return fmt.Errorf("user %s does not exist", username)
}
if err := manager.RemoveUser(username); err != nil {
@@ -252,7 +267,7 @@ func execUserChangePass(c *cli.Context) error {
if err != nil {
return err
}
if _, err := manager.User(username); err == user.ErrUserNotFound {
if _, err := manager.User(username); errors.Is(err, user.ErrUserNotFound) {
return fmt.Errorf("user %s does not exist", username)
}
if password == "" {
@@ -280,7 +295,7 @@ func execUserChangeRole(c *cli.Context) error {
if err != nil {
return err
}
if _, err := manager.User(username); err == user.ErrUserNotFound {
if _, err := manager.User(username); errors.Is(err, user.ErrUserNotFound) {
return fmt.Errorf("user %s does not exist", username)
}
if err := manager.ChangeRole(username, role); err != nil {
@@ -290,6 +305,23 @@ func execUserChangeRole(c *cli.Context) error {
return nil
}
func execUserHash(c *cli.Context) error {
manager, err := createUserManager(c)
if err != nil {
return err
}
password, err := readPasswordAndConfirm(c)
if err != nil {
return err
}
hash, err := manager.HashPassword(password)
if err != nil {
return fmt.Errorf("failed to hash password: %w", err)
}
fmt.Fprintf(c.App.Writer, "%s\n", string(hash))
return nil
}
func execUserChangeTier(c *cli.Context) error {
username := c.Args().Get(0)
tier := c.Args().Get(1)
@@ -304,7 +336,7 @@ func execUserChangeTier(c *cli.Context) error {
if err != nil {
return err
}
if _, err := manager.User(username); err == user.ErrUserNotFound {
if _, err := manager.User(username); errors.Is(err, user.ErrUserNotFound) {
return fmt.Errorf("user %s does not exist", username)
}
if tier == tierReset {
@@ -346,7 +378,15 @@ func createUserManager(c *cli.Context) (*user.Manager, error) {
if err != nil {
return nil, errors.New("if set, auth-default-access must start set to 'read-write', 'read-only', 'write-only' or 'deny-all'")
}
return user.NewManager(authFile, authStartupQueries, authDefault, user.DefaultUserPasswordBcryptCost, user.DefaultUserStatsQueueWriterInterval)
authConfig := &user.Config{
Filename: authFile,
StartupQueries: authStartupQueries,
DefaultAccess: authDefault,
ProvisionEnabled: false, // Do not re-provision users on manager initialization
BcryptCost: user.DefaultUserPasswordBcryptCost,
QueueWriterInterval: user.DefaultUserStatsQueueWriterInterval,
}
return user.NewManager(authConfig)
}
func readPasswordAndConfirm(c *cli.Context) (string, error) {

31
go.sum
View File

@@ -1,11 +1,7 @@
cel.dev/expr v0.24.0 h1:56OvJKSH3hDGL0ml5uSxZmz3/3Pq4tJ+fb1unVLAFcY=
cel.dev/expr v0.24.0/go.mod h1:hLPLo1W4QUmuYdA72RBX06QTs6MXw941piREPl3Yfiw=
cloud.google.com/go v0.121.3 h1:84RD+hQXNdY5Sw/MWVAx5O9Aui/rd5VQ9HEcdN19afo=
cloud.google.com/go v0.121.3/go.mod h1:6vWF3nJWRrEUv26mMB3FEIU/o1MQNVPG1iHdisa2SJc=
cloud.google.com/go v0.121.4 h1:cVvUiY0sX0xwyxPwdSU2KsF9knOVmtRyAMt8xou0iTs=
cloud.google.com/go v0.121.4/go.mod h1:XEBchUiHFJbz4lKBZwYBDHV/rSyfFktk737TLDU089s=
cloud.google.com/go/auth v0.16.2 h1:QvBAGFPLrDeoiNjyfVunhQ10HKNYuOwZ5noee0M5df4=
cloud.google.com/go/auth v0.16.2/go.mod h1:sRBas2Y1fB1vZTdurouM0AzuYQBMZinrUYL8EufhtEA=
cloud.google.com/go/auth v0.16.3 h1:kabzoQ9/bobUmnseYnBO6qQG7q4a/CffFRlJSxv2wCc=
cloud.google.com/go/auth v0.16.3/go.mod h1:NucRGjaXfzP1ltpcQ7On/VTZ0H4kWB5Jy+Y9Dnm76fA=
cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc=
@@ -26,8 +22,6 @@ cloud.google.com/go/storage v1.55.0 h1:NESjdAToN9u1tmhVqhXCaCwYBuvEhZLLv0gBr+2zn
cloud.google.com/go/storage v1.55.0/go.mod h1:ztSmTTwzsdXe5syLVS0YsbFxXuvEmEyZj7v7zChEmuY=
cloud.google.com/go/trace v1.11.6 h1:2O2zjPzqPYAHrn3OKl029qlqG6W8ZdYaOWRyr8NgMT4=
cloud.google.com/go/trace v1.11.6/go.mod h1:GA855OeDEBiBMzcckLPE2kDunIpC72N+Pq8WFieFjnI=
firebase.google.com/go/v4 v4.16.1 h1:Kl5cgXmM0VOWDGT1UAx6b0T2UFWa14ak0CvYqeI7Py4=
firebase.google.com/go/v4 v4.16.1/go.mod h1:aAPJq/bOyb23tBlc1K6GR+2E8sOGAeJSc8wIJVgl9SM=
firebase.google.com/go/v4 v4.17.0 h1:Bih69QV/k0YKPA1qUX04ln0aPT9IERrAo2ezibcngzE=
firebase.google.com/go/v4 v4.17.0/go.mod h1:aAPJq/bOyb23tBlc1K6GR+2E8sOGAeJSc8wIJVgl9SM=
github.com/AlekSi/pointer v1.2.0 h1:glcy/gc4h8HnG2Z3ZECSzZ1IX1x2JxRVuDzaJwQE0+w=
@@ -87,8 +81,6 @@ github.com/golang-jwt/jwt/v4 v4.4.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w
github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI=
github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang-jwt/jwt/v5 v5.2.3 h1:kkGXqQOBSDDWRhWNXTFpqGSCMyh/PLnqUvMGJPDJDs0=
github.com/golang-jwt/jwt/v5 v5.2.3/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
@@ -106,8 +98,6 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/googleapis/enterprise-certificate-proxy v0.3.6 h1:GW/XbdyBFQ8Qe+YAmFU9uHLo7OnF5tL52HFAgMmyrf4=
github.com/googleapis/enterprise-certificate-proxy v0.3.6/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA=
github.com/googleapis/gax-go/v2 v2.14.2 h1:eBLnkZ9635krYIPD+ag1USrOAI0Nr0QYF3+/3GqO0k0=
github.com/googleapis/gax-go/v2 v2.14.2/go.mod h1:ON64QhlJkhVtSqp4v1uaK92VyZ2gmvDQsweuyLV+8+w=
github.com/googleapis/gax-go/v2 v2.15.0 h1:SyjDc1mGgZU5LncH8gimWo9lW1DtIfPibOG81vgd/bo=
github.com/googleapis/gax-go/v2 v2.15.0/go.mod h1:zVVkkxAQHa1RQpg9z2AUCMnKhi0Qld9rcmyfL1OZhoc=
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
@@ -194,8 +184,6 @@ golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliY
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM=
golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U=
golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM=
golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
@@ -212,8 +200,6 @@ golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw=
golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA=
golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs=
golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8=
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
@@ -225,8 +211,6 @@ golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8=
golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@@ -241,8 +225,6 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
@@ -254,8 +236,6 @@ golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg=
golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ=
golang.org/x/term v0.33.0 h1:NuFncQrRcaRvVmgRkvM3j/F00gWIAlcmlB8ACEKmGIg=
golang.org/x/term v0.33.0/go.mod h1:s18+ql9tYWp1IfpV9DmCtQDDSRBUjKaw9M1eAv5UeF0=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
@@ -269,8 +249,6 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M=
golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA=
golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
@@ -283,23 +261,14 @@ golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/api v0.240.0 h1:PxG3AA2UIqT1ofIzWV2COM3j3JagKTKSwy7L6RHNXNU=
google.golang.org/api v0.240.0/go.mod h1:cOVEm2TpdAGHL2z+UwyS+kmlGr3bVWQQ6sYEqkKje50=
google.golang.org/api v0.242.0 h1:7Lnb1nfnpvbkCiZek6IXKdJ0MFuAZNAJKQfA1ws62xg=
google.golang.org/api v0.242.0/go.mod h1:cOVEm2TpdAGHL2z+UwyS+kmlGr3bVWQQ6sYEqkKje50=
google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM=
google.golang.org/appengine/v2 v2.0.6 h1:LvPZLGuchSBslPBp+LAhihBeGSiRh1myRoYK4NtuBIw=
google.golang.org/appengine/v2 v2.0.6/go.mod h1:WoEXGoXNfa0mLvaH5sV3ZSGXwVmy8yf7Z1JKf3J3wLI=
google.golang.org/genproto v0.0.0-20250603155806-513f23925822 h1:rHWScKit0gvAPuOnu87KpaYtjK5zBMLcULh7gxkCXu4=
google.golang.org/genproto v0.0.0-20250603155806-513f23925822/go.mod h1:HubltRL7rMh0LfnQPkMH4NPDFEWp0jw3vixw7jEM53s=
google.golang.org/genproto v0.0.0-20250715232539-7130f93afb79 h1:Nt6z9UHqSlIdIGJdz6KhTIs2VRx/iOsA5iE8bmQNcxs=
google.golang.org/genproto v0.0.0-20250715232539-7130f93afb79/go.mod h1:kTmlBHMPqR5uCZPBvwa2B18mvubkjyY3CRLI0c6fj0s=
google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822 h1:oWVWY3NzT7KJppx2UKhKmzPq4SRe0LdCijVRwvGeikY=
google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822/go.mod h1:h3c4v36UTKzUiuaOKQ6gr3S+0hovBtUrXzTG/i3+XEc=
google.golang.org/genproto/googleapis/api v0.0.0-20250715232539-7130f93afb79 h1:iOye66xuaAK0WnkPuhQPUFy8eJcmwUXqGGP3om6IxX8=
google.golang.org/genproto/googleapis/api v0.0.0-20250715232539-7130f93afb79/go.mod h1:HKJDgKsFUnv5VAGeQjz8kxcgDP0HoE0iZNp0OdZNlhE=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 h1:fc6jSaCT0vBduLYZHYrBBNY4dsWuvgyff9noRNDdBeE=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250715232539-7130f93afb79 h1:1ZwqphdOdWYXsUHgMpU/101nCtf/kSp9hOrcvFsnl10=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250715232539-7130f93afb79/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
google.golang.org/grpc v1.73.0 h1:VIWSmpI2MegBtTuFt5/JWy2oXxtjJ/e89Z70ImfD2ok=

View File

@@ -95,6 +95,8 @@ type Config struct {
AuthFile string
AuthStartupQueries string
AuthDefault user.Permission
AuthProvisionedUsers []*user.User
AuthProvisionedAccess map[string][]*user.Grant
AuthBcryptCost int
AuthStatsQueueWriterInterval time.Duration
AttachmentCacheDir string

View File

@@ -6,6 +6,8 @@ import (
"errors"
"fmt"
"net/netip"
"net/url"
"path/filepath"
"strings"
"time"
@@ -63,6 +65,10 @@ const (
INSERT INTO stats (key, value) VALUES ('messages', 0);
COMMIT;
`
builtinMessageCacheStartupQueries = `
PRAGMA foreign_keys = ON;
PRAGMA busy_timeout = 50000; -- Wait up to 5 seconds for a lock to be released
`
insertMessageQuery = `
INSERT INTO messages (mid, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, attachment_deleted, sender, user, content_type, encoding, published)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
@@ -286,7 +292,18 @@ type messageCache struct {
// newSqliteCache creates a SQLite file-backed cache
func newSqliteCache(filename, startupQueries string, cacheDuration time.Duration, batchSize int, batchTimeout time.Duration, nop bool) (*messageCache, error) {
db, err := sql.Open("sqlite3", filename)
// Parse the filename
file, datasource, err := parseSqliteFile(filename)
if err != nil {
return nil, fmt.Errorf("cannot parse cache database filename %s: %w", filename, err)
}
// Check the parent directory of the database file (makes for friendly error messages)
parentDir := filepath.Dir(filename)
if !util.FileExists(parentDir) {
return nil, fmt.Errorf("cache database directory %s does not exist or is not accessible", parentDir)
}
// Open database
db, err := sql.Open("sqlite3", fmt.Sprintf("file:%s?_busy_timeout=50000", filename))
if err != nil {
return nil, err
}
@@ -782,8 +799,21 @@ func (c *messageCache) Close() error {
return c.db.Close()
}
func parseSqliteFile(filename string) (file string, datasource string, err error) {
f, err := url.Parse(filename)
if err != nil {
return "", "", fmt.Errorf("cannot parse cache database filename %s: %w", filename, err)
} else if f.Scheme != "file" {
return f.Path, filename, nil
}
return filename, filename, nil
}
func setupMessagesDB(db *sql.DB, startupQueries string, cacheDuration time.Duration) error {
// Run startup queries
if _, err := db.Exec(builtinMessageCacheStartupQueries); err != nil {
return err
}
if startupQueries != "" {
if _, err := db.Exec(startupQueries); err != nil {
return err

View File

@@ -3,8 +3,11 @@ package server
import (
"database/sql"
"fmt"
"github.com/stretchr/testify/assert"
"net/netip"
"net/url"
"path/filepath"
"sync"
"testing"
"time"
@@ -90,6 +93,26 @@ func testCacheMessages(t *testing.T, c *messageCache) {
require.Empty(t, messages)
}
func TestSqliteCache_MessagesLock(t *testing.T) {
testCacheMessagesLock(t, newSqliteTestCache(t))
}
func TestMemCache_MessagesLock(t *testing.T) {
testCacheMessagesLock(t, newMemTestCache(t))
}
func testCacheMessagesLock(t *testing.T, c *messageCache) {
var wg sync.WaitGroup
for i := 0; i < 3000; i++ {
wg.Add(1)
go func() {
assert.Nil(t, c.AddMessage(newDefaultMessage("mytopic", "test message")))
wg.Done()
}()
}
wg.Wait()
}
func TestSqliteCache_MessagesScheduled(t *testing.T) {
testCacheMessagesScheduled(t, newSqliteTestCache(t))
}
@@ -685,6 +708,35 @@ func checkSchemaVersion(t *testing.T, db *sql.DB) {
require.Nil(t, rows.Close())
}
func TestURL(t *testing.T) {
u, _ := url.Parse("file:mem?_busy_timeout=1000&_journal_mode=WAL&_synchronous=normal&_temp_store=memory")
fmt.Printf("opaque: %+v\n", u.Opaque)
fmt.Printf("scheme: %+v\n", u.Scheme)
fmt.Printf("host: %+v\n", u.Host)
fmt.Printf("path: %+v\n", u.Path)
fmt.Printf("raw path: %+v\n", u.RawPath)
fmt.Printf("raw query: %+v\n", u.RawQuery)
fmt.Printf("query: %+v\n", u.Query())
fmt.Println("----------")
u, _ = url.Parse("myfile.db")
fmt.Printf("opaque: %+v\n", u.Opaque)
fmt.Printf("scheme: %+v\n", u.Scheme)
fmt.Printf("host: %+v\n", u.Host)
fmt.Printf("path: %+v\n", u.Path)
fmt.Printf("raw path: %+v\n", u.RawPath)
fmt.Printf("raw query: %+v\n", u.RawQuery)
fmt.Printf("query: %+v\n", u.Query())
fmt.Println("----------")
u, _ = url.Parse("htttps://abc.com/myfile.db")
fmt.Printf("opaque: %+v\n", u.Opaque)
fmt.Printf("scheme: %+v\n", u.Scheme)
fmt.Printf("host: %+v\n", u.Host)
fmt.Printf("path: %+v\n", u.Path)
fmt.Printf("raw path: %+v\n", u.RawPath)
fmt.Printf("raw query: %+v\n", u.RawQuery)
fmt.Printf("query: %+v\n", u.Query())
}
func TestMemCache_NopCache(t *testing.T) {
c, _ := newNopCache()
require.Nil(t, c.AddMessage(newDefaultMessage("mytopic", "my message")))

View File

@@ -196,7 +196,17 @@ func New(conf *Config) (*Server, error) {
}
var userManager *user.Manager
if conf.AuthFile != "" {
userManager, err = user.NewManager(conf.AuthFile, conf.AuthStartupQueries, conf.AuthDefault, conf.AuthBcryptCost, conf.AuthStatsQueueWriterInterval)
authConfig := &user.Config{
Filename: conf.AuthFile,
StartupQueries: conf.AuthStartupQueries,
DefaultAccess: conf.AuthDefault,
ProvisionEnabled: true, // Enable provisioning of users and access
ProvisionUsers: conf.AuthProvisionedUsers,
ProvisionAccess: conf.AuthProvisionedAccess,
BcryptCost: conf.AuthBcryptCost,
QueueWriterInterval: conf.AuthStatsQueueWriterInterval,
}
userManager, err = user.NewManager(authConfig)
if err != nil {
return nil, err
}

View File

@@ -82,6 +82,10 @@
# set to "read-write" (default), "read-only", "write-only" or "deny-all".
# - auth-startup-queries allows you to run commands when the database is initialized, e.g. to enable
# WAL mode. This is similar to cache-startup-queries. See above for details.
# - auth-provision-users is a list of users that are automatically created when the server starts.
# Each entry is in the format "<username>:<bcrypt-hash>:<role>", e.g. "phil:$2a$10$YLiO8U21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C:user"
# - auth-provision-access is a list of access control entries that are automatically created when the server starts.
# Each entry is in the format "<username>:<topic-pattern>:<access>", e.g. "phil:mytopic:rw" or "phil:phil-*:rw".
#
# Debian/RPM package users:
# Use /var/lib/ntfy/user.db as user database to avoid permission issues. The package
@@ -94,6 +98,8 @@
# auth-file: <filename>
# auth-default-access: "read-write"
# auth-startup-queries:
# auth-provision-users:
# auth-provision-access:
# If set, the X-Forwarded-For header (or whatever is configured in proxy-forwarded-header) is used to determine
# the visitor IP address instead of the remote address of the connection.

View File

@@ -25,7 +25,7 @@ func (s *Server) handleUsersGet(w http.ResponseWriter, r *http.Request, v *visit
for i, g := range grants[u.ID] {
userGrants[i] = &apiUserGrantResponse{
Topic: g.TopicPattern,
Permission: g.Allow.String(),
Permission: g.Permission.String(),
}
}
usersResponse[i] = &apiUserResponse{

View File

@@ -50,7 +50,7 @@ const (
);
COMMIT;
`
builtinStartupQueries = `
builtinWebPushStartupQueries = `
PRAGMA foreign_keys = ON;
`
@@ -134,7 +134,7 @@ func runWebPushStartupQueries(db *sql.DB, startupQueries string) error {
if _, err := db.Exec(startupQueries); err != nil {
return err
}
if _, err := db.Exec(builtinStartupQueries); err != nil {
if _, err := db.Exec(builtinWebPushStartupQueries); err != nil {
return err
}
return nil

View File

@@ -12,6 +12,7 @@ import (
"heckel.io/ntfy/v2/log"
"heckel.io/ntfy/v2/util"
"net/netip"
"path/filepath"
"strings"
"sync"
"time"
@@ -75,6 +76,7 @@ const (
role TEXT CHECK (role IN ('anonymous', 'admin', 'user')) NOT NULL,
prefs JSON NOT NULL DEFAULT '{}',
sync_topic TEXT NOT NULL,
provisioned INT NOT NULL,
stats_messages INT NOT NULL DEFAULT (0),
stats_emails INT NOT NULL DEFAULT (0),
stats_calls INT NOT NULL DEFAULT (0),
@@ -97,6 +99,7 @@ const (
read INT NOT NULL,
write INT NOT NULL,
owner_user_id INT,
provisioned INT NOT NULL,
PRIMARY KEY (user_id, topic),
FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE,
FOREIGN KEY (owner_user_id) REFERENCES user (id) ON DELETE CASCADE
@@ -121,8 +124,8 @@ const (
id INT PRIMARY KEY,
version INT NOT NULL
);
INSERT INTO user (id, user, pass, role, sync_topic, created)
VALUES ('` + everyoneID + `', '*', '', 'anonymous', '', UNIXEPOCH())
INSERT INTO user (id, user, pass, role, sync_topic, provisioned, created)
VALUES ('` + everyoneID + `', '*', '', 'anonymous', '', false, UNIXEPOCH())
ON CONFLICT (id) DO NOTHING;
COMMIT;
`
@@ -132,26 +135,26 @@ const (
`
selectUserByIDQuery = `
SELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.stats_messages, u.stats_emails, u.stats_calls, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.calls_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id
SELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.provisioned, u.stats_messages, u.stats_emails, u.stats_calls, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.calls_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id
FROM user u
LEFT JOIN tier t on t.id = u.tier_id
WHERE u.id = ?
`
selectUserByNameQuery = `
SELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.stats_messages, u.stats_emails, u.stats_calls, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.calls_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id
SELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.provisioned, u.stats_messages, u.stats_emails, u.stats_calls, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.calls_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id
FROM user u
LEFT JOIN tier t on t.id = u.tier_id
WHERE user = ?
`
selectUserByTokenQuery = `
SELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.stats_messages, u.stats_emails, u.stats_calls, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.calls_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id
SELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.provisioned, u.stats_messages, u.stats_emails, u.stats_calls, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.calls_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id
FROM user u
JOIN user_token tk on u.id = tk.user_id
LEFT JOIN tier t on t.id = u.tier_id
WHERE tk.token = ? AND (tk.expires = 0 OR tk.expires >= ?)
`
selectUserByStripeCustomerIDQuery = `
SELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.stats_messages, u.stats_emails, u.stats_calls, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.calls_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id
SELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.provisioned, u.stats_messages, u.stats_emails, u.stats_calls, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.calls_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id
FROM user u
LEFT JOIN tier t on t.id = u.tier_id
WHERE u.stripe_customer_id = ?
@@ -165,8 +168,8 @@ const (
`
insertUserQuery = `
INSERT INTO user (id, user, pass, role, sync_topic, created)
VALUES (?, ?, ?, ?, ?, ?)
INSERT INTO user (id, user, pass, role, sync_topic, provisioned, created)
VALUES (?, ?, ?, ?, ?, ?, ?)
`
selectUsernamesQuery = `
SELECT user
@@ -189,18 +192,18 @@ const (
deleteUserQuery = `DELETE FROM user WHERE user = ?`
upsertUserAccessQuery = `
INSERT INTO user_access (user_id, topic, read, write, owner_user_id)
VALUES ((SELECT id FROM user WHERE user = ?), ?, ?, ?, (SELECT IIF(?='',NULL,(SELECT id FROM user WHERE user=?))))
INSERT INTO user_access (user_id, topic, read, write, owner_user_id, provisioned)
VALUES ((SELECT id FROM user WHERE user = ?), ?, ?, ?, (SELECT IIF(?='',NULL,(SELECT id FROM user WHERE user=?))), ?)
ON CONFLICT (user_id, topic)
DO UPDATE SET read=excluded.read, write=excluded.write, owner_user_id=excluded.owner_user_id
DO UPDATE SET read=excluded.read, write=excluded.write, owner_user_id=excluded.owner_user_id, provisioned=excluded.provisioned
`
selectUserAllAccessQuery = `
SELECT user_id, topic, read, write
SELECT user_id, topic, read, write, provisioned
FROM user_access
ORDER BY LENGTH(topic) DESC, write DESC, read DESC, topic
`
selectUserAccessQuery = `
SELECT topic, read, write
SELECT topic, read, write, provisioned
FROM user_access
WHERE user_id = (SELECT id FROM user WHERE user = ?)
ORDER BY LENGTH(topic) DESC, write DESC, read DESC, topic
@@ -244,7 +247,8 @@ const (
WHERE user_id = (SELECT id FROM user WHERE user = ?)
OR owner_user_id = (SELECT id FROM user WHERE user = ?)
`
deleteTopicAccessQuery = `
deleteUserAccessProvisionedQuery = `DELETE FROM user_access WHERE provisioned = 1`
deleteTopicAccessQuery = `
DELETE FROM user_access
WHERE (user_id = (SELECT id FROM user WHERE user = ?) OR owner_user_id = (SELECT id FROM user WHERE user = ?))
AND topic = ?
@@ -312,7 +316,7 @@ const (
// Schema management queries
const (
currentSchemaVersion = 5
currentSchemaVersion = 6
insertSchemaVersion = `INSERT INTO schemaVersion VALUES (1, ?)`
updateSchemaVersion = `UPDATE schemaVersion SET version = ? WHERE id = 1`
selectSchemaVersionQuery = `SELECT version FROM schemaVersion WHERE id = 1`
@@ -427,6 +431,82 @@ const (
migrate4To5UpdateQueries = `
UPDATE user_access SET topic = REPLACE(topic, '_', '\_');
`
// 5 -> 6
migrate5To6UpdateQueries = `
PRAGMA foreign_keys=off;
-- Alter user table: Add provisioned column
ALTER TABLE user RENAME TO user_old;
CREATE TABLE IF NOT EXISTS user (
id TEXT PRIMARY KEY,
tier_id TEXT,
user TEXT NOT NULL,
pass TEXT NOT NULL,
role TEXT CHECK (role IN ('anonymous', 'admin', 'user')) NOT NULL,
prefs JSON NOT NULL DEFAULT '{}',
sync_topic TEXT NOT NULL,
provisioned INT NOT NULL,
stats_messages INT NOT NULL DEFAULT (0),
stats_emails INT NOT NULL DEFAULT (0),
stats_calls INT NOT NULL DEFAULT (0),
stripe_customer_id TEXT,
stripe_subscription_id TEXT,
stripe_subscription_status TEXT,
stripe_subscription_interval TEXT,
stripe_subscription_paid_until INT,
stripe_subscription_cancel_at INT,
created INT NOT NULL,
deleted INT,
FOREIGN KEY (tier_id) REFERENCES tier (id)
);
INSERT INTO user
SELECT
id,
tier_id,
user,
pass,
role,
prefs,
sync_topic,
0,
stats_messages,
stats_emails,
stats_calls,
stripe_customer_id,
stripe_subscription_id,
stripe_subscription_status,
stripe_subscription_interval,
stripe_subscription_paid_until,
stripe_subscription_cancel_at,
created, deleted
FROM user_old;
DROP TABLE user_old;
-- Alter user_access table: Add provisioned column
ALTER TABLE user_access RENAME TO user_access_old;
CREATE TABLE user_access (
user_id TEXT NOT NULL,
topic TEXT NOT NULL,
read INT NOT NULL,
write INT NOT NULL,
owner_user_id INT,
provisioned INTEGER NOT NULL,
PRIMARY KEY (user_id, topic),
FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE,
FOREIGN KEY (owner_user_id) REFERENCES user (id) ON DELETE CASCADE
);
INSERT INTO user_access SELECT *, 0 FROM user_access_old;
DROP TABLE user_access_old;
-- Recreate indices
CREATE UNIQUE INDEX idx_user ON user (user);
CREATE UNIQUE INDEX idx_user_stripe_customer_id ON user (stripe_customer_id);
CREATE UNIQUE INDEX idx_user_stripe_subscription_id ON user (stripe_subscription_id);
-- Re-enable foreign keys
PRAGMA foreign_keys=on;
`
)
var (
@@ -435,42 +515,69 @@ var (
2: migrateFrom2,
3: migrateFrom3,
4: migrateFrom4,
5: migrateFrom5,
}
)
// Manager is an implementation of Manager. It stores users and access control list
// in a SQLite database.
type Manager struct {
db *sql.DB
defaultAccess Permission // Default permission if no ACL matches
statsQueue map[string]*Stats // "Queue" to asynchronously write user stats to the database (UserID -> Stats)
tokenQueue map[string]*TokenUpdate // "Queue" to asynchronously write token access stats to the database (Token ID -> TokenUpdate)
bcryptCost int // Makes testing easier
mu sync.Mutex
config *Config
db *sql.DB
statsQueue map[string]*Stats // "Queue" to asynchronously write user stats to the database (UserID -> Stats)
tokenQueue map[string]*TokenUpdate // "Queue" to asynchronously write token access stats to the database (Token ID -> TokenUpdate)
mu sync.Mutex
}
// Config holds the configuration for the user Manager
type Config struct {
Filename string // Database filename, e.g. "/var/lib/ntfy/user.db"
StartupQueries string // Queries to run on startup, e.g. to create initial users or tiers
DefaultAccess Permission // Default permission if no ACL matches
ProvisionEnabled bool // Enable auto-provisioning of users and access grants, disabled for "ntfy user" commands
ProvisionUsers []*User // Predefined users to create on startup
ProvisionAccess map[string][]*Grant // Predefined access grants to create on startup
QueueWriterInterval time.Duration // Interval for the async queue writer to flush stats and token updates to the database
BcryptCost int // Cost of generated passwords; lowering makes testing faster
}
var _ Auther = (*Manager)(nil)
// NewManager creates a new Manager instance
func NewManager(filename, startupQueries string, defaultAccess Permission, bcryptCost int, queueWriterInterval time.Duration) (*Manager, error) {
db, err := sql.Open("sqlite3", filename)
func NewManager(config *Config) (*Manager, error) {
// Set defaults
if config.BcryptCost <= 0 {
config.BcryptCost = DefaultUserPasswordBcryptCost
}
if config.QueueWriterInterval.Seconds() <= 0 {
config.QueueWriterInterval = DefaultUserStatsQueueWriterInterval
}
// Check the parent directory of the database file (makes for friendly error messages)
parentDir := filepath.Dir(config.Filename)
if !util.FileExists(parentDir) {
return nil, fmt.Errorf("user database directory %s does not exist or is not accessible", parentDir)
}
// Open DB and run setup queries
db, err := sql.Open("sqlite3", config.Filename)
if err != nil {
return nil, err
}
if err := setupDB(db); err != nil {
return nil, err
}
if err := runStartupQueries(db, startupQueries); err != nil {
if err := runStartupQueries(db, config.StartupQueries); err != nil {
return nil, err
}
manager := &Manager{
db: db,
defaultAccess: defaultAccess,
statsQueue: make(map[string]*Stats),
tokenQueue: make(map[string]*TokenUpdate),
bcryptCost: bcryptCost,
db: db,
config: config,
statsQueue: make(map[string]*Stats),
tokenQueue: make(map[string]*TokenUpdate),
}
go manager.asyncQueueWriter(queueWriterInterval)
if err := manager.maybeProvisionUsersAndAccess(); err != nil {
return nil, err
}
go manager.asyncQueueWriter(config.QueueWriterInterval)
return manager, nil
}
@@ -567,7 +674,7 @@ func (a *Manager) Tokens(userID string) ([]*Token, error) {
tokens := make([]*Token, 0)
for {
token, err := a.readToken(rows)
if err == ErrTokenNotFound {
if errors.Is(err, ErrTokenNotFound) {
break
} else if err != nil {
return nil, err
@@ -843,7 +950,7 @@ func (a *Manager) Authorize(user *User, topic string, perm Permission) error {
}
defer rows.Close()
if !rows.Next() {
return a.resolvePerms(a.defaultAccess, perm)
return a.resolvePerms(a.config.DefaultAccess, perm)
}
var read, write bool
if err := rows.Scan(&read, &write); err != nil {
@@ -865,23 +972,33 @@ func (a *Manager) resolvePerms(base, perm Permission) error {
// AddUser adds a user with the given username, password and role
func (a *Manager) AddUser(username, password string, role Role, hashed bool) error {
return execTx(a.db, func(tx *sql.Tx) error {
return a.addUserTx(tx, username, password, role, hashed, false)
})
}
// AddUser adds a user with the given username, password and role
func (a *Manager) addUserTx(tx *sql.Tx, username, password string, role Role, hashed, provisioned bool) error {
if !AllowedUsername(username) || !AllowedRole(role) {
return ErrInvalidArgument
}
var hash []byte
var hash string
var err error = nil
if hashed {
hash = []byte(password)
hash = password
if err := AllowedPasswordHash(hash); err != nil {
return err
}
} else {
hash, err = bcrypt.GenerateFromPassword([]byte(password), a.bcryptCost)
hash, err = a.HashPassword(password)
if err != nil {
return err
}
}
userID := util.RandomStringPrefix(userIDPrefix, userIDLength)
syncTopic, now := util.RandomStringPrefix(syncTopicPrefix, syncTopicLength), time.Now().Unix()
if _, err = a.db.Exec(insertUserQuery, userID, username, hash, role, syncTopic, now); err != nil {
if sqliteErr, ok := err.(sqlite3.Error); ok && sqliteErr.ExtendedCode == sqlite3.ErrConstraintUnique {
if _, err = tx.Exec(insertUserQuery, userID, username, hash, role, syncTopic, provisioned, now); err != nil {
if errors.Is(err, sqlite3.ErrConstraintUnique) {
return ErrUserExists
}
return err
@@ -892,11 +1009,17 @@ func (a *Manager) AddUser(username, password string, role Role, hashed bool) err
// RemoveUser deletes the user with the given username. The function returns nil on success, even
// if the user did not exist in the first place.
func (a *Manager) RemoveUser(username string) error {
return execTx(a.db, func(tx *sql.Tx) error {
return a.removeUserTx(tx, username)
})
}
func (a *Manager) removeUserTx(tx *sql.Tx, username string) error {
if !AllowedUsername(username) {
return ErrInvalidArgument
}
// Rows in user_access, user_token, etc. are deleted via foreign keys
if _, err := a.db.Exec(deleteUserQuery, username); err != nil {
if _, err := tx.Exec(deleteUserQuery, username); err != nil {
return err
}
return nil
@@ -1010,24 +1133,26 @@ func (a *Manager) userByToken(token string) (*User, error) {
func (a *Manager) readUser(rows *sql.Rows) (*User, error) {
defer rows.Close()
var id, username, hash, role, prefs, syncTopic string
var provisioned bool
var stripeCustomerID, stripeSubscriptionID, stripeSubscriptionStatus, stripeSubscriptionInterval, stripeMonthlyPriceID, stripeYearlyPriceID, tierID, tierCode, tierName sql.NullString
var messages, emails, calls int64
var messagesLimit, messagesExpiryDuration, emailsLimit, callsLimit, reservationsLimit, attachmentFileSizeLimit, attachmentTotalSizeLimit, attachmentExpiryDuration, attachmentBandwidthLimit, stripeSubscriptionPaidUntil, stripeSubscriptionCancelAt, deleted sql.NullInt64
if !rows.Next() {
return nil, ErrUserNotFound
}
if err := rows.Scan(&id, &username, &hash, &role, &prefs, &syncTopic, &messages, &emails, &calls, &stripeCustomerID, &stripeSubscriptionID, &stripeSubscriptionStatus, &stripeSubscriptionInterval, &stripeSubscriptionPaidUntil, &stripeSubscriptionCancelAt, &deleted, &tierID, &tierCode, &tierName, &messagesLimit, &messagesExpiryDuration, &emailsLimit, &callsLimit, &reservationsLimit, &attachmentFileSizeLimit, &attachmentTotalSizeLimit, &attachmentExpiryDuration, &attachmentBandwidthLimit, &stripeMonthlyPriceID, &stripeYearlyPriceID); err != nil {
if err := rows.Scan(&id, &username, &hash, &role, &prefs, &syncTopic, &provisioned, &messages, &emails, &calls, &stripeCustomerID, &stripeSubscriptionID, &stripeSubscriptionStatus, &stripeSubscriptionInterval, &stripeSubscriptionPaidUntil, &stripeSubscriptionCancelAt, &deleted, &tierID, &tierCode, &tierName, &messagesLimit, &messagesExpiryDuration, &emailsLimit, &callsLimit, &reservationsLimit, &attachmentFileSizeLimit, &attachmentTotalSizeLimit, &attachmentExpiryDuration, &attachmentBandwidthLimit, &stripeMonthlyPriceID, &stripeYearlyPriceID); err != nil {
return nil, err
} else if err := rows.Err(); err != nil {
return nil, err
}
user := &User{
ID: id,
Name: username,
Hash: hash,
Role: Role(role),
Prefs: &Prefs{},
SyncTopic: syncTopic,
ID: id,
Name: username,
Hash: hash,
Role: Role(role),
Prefs: &Prefs{},
SyncTopic: syncTopic,
Provisioned: provisioned,
Stats: &Stats{
Messages: messages,
Emails: emails,
@@ -1078,8 +1203,8 @@ func (a *Manager) AllGrants() (map[string][]Grant, error) {
grants := make(map[string][]Grant, 0)
for rows.Next() {
var userID, topic string
var read, write bool
if err := rows.Scan(&userID, &topic, &read, &write); err != nil {
var read, write, provisioned bool
if err := rows.Scan(&userID, &topic, &read, &write, &provisioned); err != nil {
return nil, err
} else if err := rows.Err(); err != nil {
return nil, err
@@ -1089,7 +1214,8 @@ func (a *Manager) AllGrants() (map[string][]Grant, error) {
}
grants[userID] = append(grants[userID], Grant{
TopicPattern: fromSQLWildcard(topic),
Allow: NewPermission(read, write),
Permission: NewPermission(read, write),
Provisioned: provisioned,
})
}
return grants, nil
@@ -1105,15 +1231,16 @@ func (a *Manager) Grants(username string) ([]Grant, error) {
grants := make([]Grant, 0)
for rows.Next() {
var topic string
var read, write bool
if err := rows.Scan(&topic, &read, &write); err != nil {
var read, write, provisioned bool
if err := rows.Scan(&topic, &read, &write, &provisioned); err != nil {
return nil, err
} else if err := rows.Err(); err != nil {
return nil, err
}
grants = append(grants, Grant{
TopicPattern: fromSQLWildcard(topic),
Allow: NewPermission(read, write),
Permission: NewPermission(read, write),
Provisioned: provisioned,
})
}
return grants, nil
@@ -1199,18 +1326,26 @@ func (a *Manager) ReservationOwner(topic string) (string, error) {
// ChangePassword changes a user's password
func (a *Manager) ChangePassword(username, password string, hashed bool) error {
var hash []byte
var err error
return execTx(a.db, func(tx *sql.Tx) error {
return a.changePasswordTx(tx, username, password, hashed)
})
}
func (a *Manager) changePasswordTx(tx *sql.Tx, username, password string, hashed bool) error {
var hash string
var err error
if hashed {
hash = []byte(password)
hash = password
if err := AllowedPasswordHash(hash); err != nil {
return err
}
} else {
hash, err = bcrypt.GenerateFromPassword([]byte(password), a.bcryptCost)
hash, err = a.HashPassword(password)
if err != nil {
return err
}
}
if _, err := a.db.Exec(updateUserPassQuery, hash, username); err != nil {
if _, err := tx.Exec(updateUserPassQuery, hash, username); err != nil {
return err
}
return nil
@@ -1219,14 +1354,20 @@ func (a *Manager) ChangePassword(username, password string, hashed bool) error {
// ChangeRole changes a user's role. When a role is changed from RoleUser to RoleAdmin,
// all existing access control entries (Grant) are removed, since they are no longer needed.
func (a *Manager) ChangeRole(username string, role Role) error {
return execTx(a.db, func(tx *sql.Tx) error {
return a.changeRoleTx(tx, username, role)
})
}
func (a *Manager) changeRoleTx(tx *sql.Tx, username string, role Role) error {
if !AllowedUsername(username) || !AllowedRole(role) {
return ErrInvalidArgument
}
if _, err := a.db.Exec(updateUserRoleQuery, string(role), username); err != nil {
if _, err := tx.Exec(updateUserRoleQuery, string(role), username); err != nil {
return err
}
if role == RoleAdmin {
if _, err := a.db.Exec(deleteUserAccessQuery, username, username); err != nil {
if _, err := tx.Exec(deleteUserAccessQuery, username, username); err != nil {
return err
}
}
@@ -1306,13 +1447,19 @@ func (a *Manager) AllowReservation(username string, topic string) error {
// read/write access to a topic. The parameter topicPattern may include wildcards (*). The ACL entry
// owner may either be a user (username), or the system (empty).
func (a *Manager) AllowAccess(username string, topicPattern string, permission Permission) error {
return execTx(a.db, func(tx *sql.Tx) error {
return a.allowAccessTx(tx, username, topicPattern, permission, false)
})
}
func (a *Manager) allowAccessTx(tx *sql.Tx, username string, topicPattern string, permission Permission, provisioned bool) error {
if !AllowedUsername(username) && username != Everyone {
return ErrInvalidArgument
} else if !AllowedTopicPattern(topicPattern) {
return ErrInvalidArgument
}
owner := ""
if _, err := a.db.Exec(upsertUserAccessQuery, username, toSQLWildcard(topicPattern), permission.IsRead(), permission.IsWrite(), owner, owner); err != nil {
if _, err := tx.Exec(upsertUserAccessQuery, username, toSQLWildcard(topicPattern), permission.IsRead(), permission.IsWrite(), owner, owner, provisioned); err != nil {
return err
}
return nil
@@ -1349,10 +1496,10 @@ func (a *Manager) AddReservation(username string, topic string, everyone Permiss
return err
}
defer tx.Rollback()
if _, err := tx.Exec(upsertUserAccessQuery, username, escapeUnderscore(topic), true, true, username, username); err != nil {
if _, err := tx.Exec(upsertUserAccessQuery, username, escapeUnderscore(topic), true, true, username, username, false); err != nil {
return err
}
if _, err := tx.Exec(upsertUserAccessQuery, Everyone, escapeUnderscore(topic), everyone.IsRead(), everyone.IsWrite(), username, username); err != nil {
if _, err := tx.Exec(upsertUserAccessQuery, Everyone, escapeUnderscore(topic), everyone.IsRead(), everyone.IsWrite(), username, username, false); err != nil {
return err
}
return tx.Commit()
@@ -1387,7 +1534,7 @@ func (a *Manager) RemoveReservations(username string, topics ...string) error {
// DefaultAccess returns the default read/write access if no access control entry matches
func (a *Manager) DefaultAccess() Permission {
return a.defaultAccess
return a.config.DefaultAccess
}
// AddTier creates a new tier in the database
@@ -1500,11 +1647,81 @@ func (a *Manager) readTier(rows *sql.Rows) (*Tier, error) {
}, nil
}
// HashPassword hashes the given password using bcrypt with the configured cost
func (a *Manager) HashPassword(password string) (string, error) {
hash, err := bcrypt.GenerateFromPassword([]byte(password), a.config.BcryptCost)
if err != nil {
return "", err
}
return string(hash), nil
}
// Close closes the underlying database
func (a *Manager) Close() error {
return a.db.Close()
}
func (a *Manager) maybeProvisionUsersAndAccess() error {
if !a.config.ProvisionEnabled {
return nil
}
users, err := a.Users()
if err != nil {
return err
}
provisionUsernames := util.Map(a.config.ProvisionUsers, func(u *User) string {
return u.Name
})
return execTx(a.db, func(tx *sql.Tx) error {
// Remove users that are provisioned, but not in the config anymore
for _, user := range users {
if user.Name == Everyone {
continue
} else if user.Provisioned && !util.Contains(provisionUsernames, user.Name) {
log.Tag(tag).Info("Removing previously provisioned user %s", user.Name)
if err := a.removeUserTx(tx, user.Name); err != nil {
return fmt.Errorf("failed to remove provisioned user %s: %v", user.Name, err)
}
}
}
// Add or update provisioned users
for _, user := range a.config.ProvisionUsers {
if user.Name == Everyone {
continue
}
existingUser, exists := util.Find(users, func(u *User) bool {
return u.Name == user.Name
})
if !exists {
log.Tag(tag).Info("Adding provisioned user %s", user.Name)
if err := a.addUserTx(tx, user.Name, user.Hash, user.Role, true, true); err != nil && !errors.Is(err, ErrUserExists) {
return fmt.Errorf("failed to add provisioned user %s: %v", user.Name, err)
}
} else if existingUser.Provisioned && (existingUser.Hash != user.Hash || existingUser.Role != user.Role) {
log.Tag(tag).Info("Updating provisioned user %s", user.Name)
if err := a.changePasswordTx(tx, user.Name, user.Hash, true); err != nil {
return fmt.Errorf("failed to change password for provisioned user %s: %v", user.Name, err)
}
if err := a.changeRoleTx(tx, user.Name, user.Role); err != nil {
return fmt.Errorf("failed to change role for provisioned user %s: %v", user.Name, err)
}
}
}
// Remove and (re-)add provisioned grants
if _, err := tx.Exec(deleteUserAccessProvisionedQuery); err != nil {
return err
}
for username, grants := range a.config.ProvisionAccess {
for _, grant := range grants {
if err := a.allowAccessTx(tx, username, grant.TopicPattern, grant.Permission, true); err != nil {
return err
}
}
}
return nil
})
}
// toSQLWildcard converts a wildcard string to a SQL wildcard string. It only allows '*' as wildcards,
// and escapes '_', assuming '\' as escape character.
func toSQLWildcard(s string) string {
@@ -1676,6 +1893,22 @@ func migrateFrom4(db *sql.DB) error {
return tx.Commit()
}
func migrateFrom5(db *sql.DB) error {
log.Tag(tag).Info("Migrating user database schema: from 5 to 6")
tx, err := db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
if _, err := tx.Exec(migrate5To6UpdateQueries); err != nil {
return err
}
if _, err := tx.Exec(updateSchemaVersion, 6); err != nil {
return err
}
return tx.Commit()
}
func nullString(s string) sql.NullString {
if s == "" {
return sql.NullString{}
@@ -1689,3 +1922,18 @@ func nullInt64(v int64) sql.NullInt64 {
}
return sql.NullInt64{Int64: v, Valid: true}
}
// execTx executes a function in a transaction. If the function returns an error, the transaction is rolled back.
func execTx(db *sql.DB, f func(tx *sql.Tx) error) error {
tx, err := db.Begin()
if err != nil {
return err
}
if err := f(tx); err != nil {
if e := tx.Rollback(); e != nil {
return err
}
return err
}
return tx.Commit()
}

View File

@@ -52,10 +52,10 @@ func TestManager_FullScenario_Default_DenyAll(t *testing.T) {
benGrants, err := a.Grants("ben")
require.Nil(t, err)
require.Equal(t, []Grant{
{"everyonewrite", PermissionDenyAll},
{"mytopic", PermissionReadWrite},
{"writeme", PermissionWrite},
{"readme", PermissionRead},
{"everyonewrite", PermissionDenyAll, false},
{"mytopic", PermissionReadWrite, false},
{"writeme", PermissionWrite, false},
{"readme", PermissionRead, false},
}, benGrants)
john, err := a.Authenticate("john", "john")
@@ -67,10 +67,10 @@ func TestManager_FullScenario_Default_DenyAll(t *testing.T) {
johnGrants, err := a.Grants("john")
require.Nil(t, err)
require.Equal(t, []Grant{
{"mytopic_deny*", PermissionDenyAll},
{"mytopic_ro*", PermissionRead},
{"mytopic*", PermissionReadWrite},
{"*", PermissionRead},
{"mytopic_deny*", PermissionDenyAll, false},
{"mytopic_ro*", PermissionRead, false},
{"mytopic*", PermissionReadWrite, false},
{"*", PermissionRead, false},
}, johnGrants)
notben, err := a.Authenticate("ben", "this is wrong")
@@ -277,10 +277,10 @@ func TestManager_UserManagement(t *testing.T) {
benGrants, err := a.Grants("ben")
require.Nil(t, err)
require.Equal(t, []Grant{
{"everyonewrite", PermissionDenyAll},
{"mytopic", PermissionReadWrite},
{"writeme", PermissionWrite},
{"readme", PermissionRead},
{"everyonewrite", PermissionDenyAll, false},
{"mytopic", PermissionReadWrite, false},
{"writeme", PermissionWrite, false},
{"readme", PermissionRead, false},
}, benGrants)
everyone, err := a.User(Everyone)
@@ -292,8 +292,8 @@ func TestManager_UserManagement(t *testing.T) {
everyoneGrants, err := a.Grants(Everyone)
require.Nil(t, err)
require.Equal(t, []Grant{
{"everyonewrite", PermissionReadWrite},
{"announcements", PermissionRead},
{"everyonewrite", PermissionReadWrite, false},
{"announcements", PermissionRead, false},
}, everyoneGrants)
// Ben: Before revoking
@@ -340,7 +340,7 @@ func TestManager_UserManagement(t *testing.T) {
func TestManager_ChangePassword(t *testing.T) {
a := newTestManager(t, PermissionDenyAll)
require.Nil(t, a.AddUser("phil", "phil", RoleAdmin, false))
require.Nil(t, a.AddUser("jane", "$2b$10$OyqU72muEy7VMd1SAU2Iru5IbeSMgrtCGHu/fWLmxL1MwlijQXWbG", RoleUser, true))
require.Nil(t, a.AddUser("jane", "$2a$10$OyqU72muEy7VMd1SAU2Iru5IbeSMgrtCGHu/fWLmxL1MwlijQXWbG", RoleUser, true))
_, err := a.Authenticate("phil", "phil")
require.Nil(t, err)
@@ -354,7 +354,7 @@ func TestManager_ChangePassword(t *testing.T) {
_, err = a.Authenticate("phil", "newpass")
require.Nil(t, err)
require.Nil(t, a.ChangePassword("jane", "$2b$10$CNaCW.q1R431urlbQ5Drh.zl48TiiOeJSmZgfcswkZiPbJGQ1ApSS", true))
require.Nil(t, a.ChangePassword("jane", "$2a$10$CNaCW.q1R431urlbQ5Drh.zl48TiiOeJSmZgfcswkZiPbJGQ1ApSS", true))
_, err = a.Authenticate("jane", "jane")
require.Equal(t, ErrUnauthenticated, err)
_, err = a.Authenticate("jane", "newpass")
@@ -489,12 +489,12 @@ func TestManager_ChangeRoleFromTierUserToAdmin(t *testing.T) {
benGrants, err := a.Grants("ben")
require.Nil(t, err)
require.Equal(t, 1, len(benGrants))
require.Equal(t, PermissionReadWrite, benGrants[0].Allow)
require.Equal(t, PermissionReadWrite, benGrants[0].Permission)
everyoneGrants, err := a.Grants(Everyone)
require.Nil(t, err)
require.Equal(t, 1, len(everyoneGrants))
require.Equal(t, PermissionDenyAll, everyoneGrants[0].Allow)
require.Equal(t, PermissionDenyAll, everyoneGrants[0].Permission)
benReservations, err := a.Reservations("ben")
require.Nil(t, err)
@@ -731,7 +731,14 @@ func TestManager_Token_MaxCount_AutoDelete(t *testing.T) {
}
func TestManager_EnqueueStats_ResetStats(t *testing.T) {
a, err := NewManager(filepath.Join(t.TempDir(), "db"), "", PermissionReadWrite, bcrypt.MinCost, 1500*time.Millisecond)
conf := &Config{
Filename: filepath.Join(t.TempDir(), "db"),
StartupQueries: "",
DefaultAccess: PermissionReadWrite,
BcryptCost: bcrypt.MinCost,
QueueWriterInterval: 1500 * time.Millisecond,
}
a, err := NewManager(conf)
require.Nil(t, err)
require.Nil(t, a.AddUser("ben", "ben", RoleUser, false))
@@ -773,7 +780,14 @@ func TestManager_EnqueueStats_ResetStats(t *testing.T) {
}
func TestManager_EnqueueTokenUpdate(t *testing.T) {
a, err := NewManager(filepath.Join(t.TempDir(), "db"), "", PermissionReadWrite, bcrypt.MinCost, 500*time.Millisecond)
conf := &Config{
Filename: filepath.Join(t.TempDir(), "db"),
StartupQueries: "",
DefaultAccess: PermissionReadWrite,
BcryptCost: bcrypt.MinCost,
QueueWriterInterval: 500 * time.Millisecond,
}
a, err := NewManager(conf)
require.Nil(t, err)
require.Nil(t, a.AddUser("ben", "ben", RoleUser, false))
@@ -806,7 +820,14 @@ func TestManager_EnqueueTokenUpdate(t *testing.T) {
}
func TestManager_ChangeSettings(t *testing.T) {
a, err := NewManager(filepath.Join(t.TempDir(), "db"), "", PermissionReadWrite, bcrypt.MinCost, 1500*time.Millisecond)
conf := &Config{
Filename: filepath.Join(t.TempDir(), "db"),
StartupQueries: "",
DefaultAccess: PermissionReadWrite,
BcryptCost: bcrypt.MinCost,
QueueWriterInterval: 1500 * time.Millisecond,
}
a, err := NewManager(conf)
require.Nil(t, err)
require.Nil(t, a.AddUser("ben", "ben", RoleUser, false))
@@ -1075,6 +1096,103 @@ func TestManager_Topic_Wildcard_With_Underscore(t *testing.T) {
require.Equal(t, ErrUnauthorized, a.Authorize(nil, "mytopicX", PermissionWrite))
}
func TestManager_WithProvisionedUsers(t *testing.T) {
f := filepath.Join(t.TempDir(), "user.db")
conf := &Config{
Filename: f,
DefaultAccess: PermissionReadWrite,
ProvisionEnabled: true,
ProvisionUsers: []*User{
{Name: "philuser", Hash: "$2a$10$YLiO8U21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C", Role: RoleUser},
{Name: "philadmin", Hash: "$2a$10$YLiO8U21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C", Role: RoleAdmin},
},
ProvisionAccess: map[string][]*Grant{
"philuser": {
{TopicPattern: "stats", Permission: PermissionReadWrite},
{TopicPattern: "secret", Permission: PermissionRead},
},
},
}
a, err := NewManager(conf)
require.Nil(t, err)
// Manually add user
require.Nil(t, a.AddUser("philmanual", "manual", RoleUser, false))
// Check that the provisioned users are there
users, err := a.Users()
require.Nil(t, err)
require.Len(t, users, 4)
require.Equal(t, "philadmin", users[0].Name)
require.Equal(t, RoleAdmin, users[0].Role)
require.Equal(t, "philmanual", users[1].Name)
require.Equal(t, RoleUser, users[1].Role)
grants, err := a.Grants("philuser")
require.Nil(t, err)
require.Equal(t, "philuser", users[2].Name)
require.Equal(t, RoleUser, users[2].Role)
require.Equal(t, 2, len(grants))
require.Equal(t, "secret", grants[0].TopicPattern)
require.Equal(t, PermissionRead, grants[0].Permission)
require.Equal(t, "stats", grants[1].TopicPattern)
require.Equal(t, PermissionReadWrite, grants[1].Permission)
require.Equal(t, "*", users[3].Name)
// Re-open the DB (second app start)
require.Nil(t, a.db.Close())
conf.ProvisionUsers = []*User{
{Name: "philuser", Hash: "$2a$10$AAAU21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C", Role: RoleUser},
}
conf.ProvisionAccess = map[string][]*Grant{
"philuser": {
{TopicPattern: "stats12", Permission: PermissionReadWrite},
{TopicPattern: "secret12", Permission: PermissionRead},
},
}
a, err = NewManager(conf)
require.Nil(t, err)
// Check that the provisioned users are there
users, err = a.Users()
require.Nil(t, err)
require.Len(t, users, 3)
require.Equal(t, "philmanual", users[0].Name)
require.Equal(t, RoleUser, users[0].Role)
grants, err = a.Grants("philuser")
require.Nil(t, err)
require.Equal(t, "philuser", users[1].Name)
require.Equal(t, RoleUser, users[1].Role)
require.Equal(t, 2, len(grants))
require.Equal(t, "secret12", grants[0].TopicPattern)
require.Equal(t, PermissionRead, grants[0].Permission)
require.Equal(t, "stats12", grants[1].TopicPattern)
require.Equal(t, PermissionReadWrite, grants[1].Permission)
require.Equal(t, "*", users[2].Name)
// Re-open the DB again (third app start)
require.Nil(t, a.db.Close())
conf.ProvisionUsers = []*User{}
conf.ProvisionAccess = map[string][]*Grant{}
a, err = NewManager(conf)
require.Nil(t, err)
// Check that the provisioned users are there
users, err = a.Users()
require.Nil(t, err)
require.Len(t, users, 2)
require.Equal(t, "philmanual", users[0].Name)
require.Equal(t, RoleUser, users[0].Role)
require.Equal(t, "*", users[1].Name)
}
func TestToFromSQLWildcard(t *testing.T) {
require.Equal(t, "up%", toSQLWildcard("up*"))
require.Equal(t, "up\\_%", toSQLWildcard("up_*"))
@@ -1162,16 +1280,16 @@ func TestMigrationFrom1(t *testing.T) {
require.NotEqual(t, ben.SyncTopic, phil.SyncTopic)
require.Equal(t, 2, len(benGrants))
require.Equal(t, "secret", benGrants[0].TopicPattern)
require.Equal(t, PermissionRead, benGrants[0].Allow)
require.Equal(t, PermissionRead, benGrants[0].Permission)
require.Equal(t, "stats", benGrants[1].TopicPattern)
require.Equal(t, PermissionReadWrite, benGrants[1].Allow)
require.Equal(t, PermissionReadWrite, benGrants[1].Permission)
require.Equal(t, "u_everyone", everyone.ID)
require.Equal(t, Everyone, everyone.Name)
require.Equal(t, RoleAnonymous, everyone.Role)
require.Equal(t, 1, len(everyoneGrants))
require.Equal(t, "stats", everyoneGrants[0].TopicPattern)
require.Equal(t, PermissionRead, everyoneGrants[0].Allow)
require.Equal(t, PermissionRead, everyoneGrants[0].Permission)
}
func TestMigrationFrom4(t *testing.T) {
@@ -1336,7 +1454,14 @@ func newTestManager(t *testing.T, defaultAccess Permission) *Manager {
}
func newTestManagerFromFile(t *testing.T, filename, startupQueries string, defaultAccess Permission, bcryptCost int, statsWriterInterval time.Duration) *Manager {
a, err := NewManager(filename, startupQueries, defaultAccess, bcryptCost, statsWriterInterval)
conf := &Config{
Filename: filename,
StartupQueries: startupQueries,
DefaultAccess: defaultAccess,
BcryptCost: bcryptCost,
QueueWriterInterval: statsWriterInterval,
}
a, err := NewManager(conf)
require.Nil(t, err)
return a
}

View File

@@ -12,17 +12,18 @@ import (
// User is a struct that represents a user
type User struct {
ID string
Name string
Hash string // password hash (bcrypt)
Token string // Only set if token was used to log in
Role Role
Prefs *Prefs
Tier *Tier
Stats *Stats
Billing *Billing
SyncTopic string
Deleted bool
ID string
Name string
Hash string // Password hash (bcrypt)
Token string // Only set if token was used to log in
Role Role
Prefs *Prefs
Tier *Tier
Stats *Stats
Billing *Billing
SyncTopic string
Provisioned bool // Whether the user was provisioned by the config file
Deleted bool // Whether the user was soft-deleted
}
// TierID returns the ID of the User.Tier, or an empty string if the user has no tier,
@@ -148,7 +149,8 @@ type Billing struct {
// Grant is a struct that represents an access control entry to a topic by a user
type Grant struct {
TopicPattern string // May include wildcard (*)
Allow Permission
Permission Permission
Provisioned bool // Whether the grant was provisioned by the config file
}
// Reservation is a struct that represents the ownership over a topic by a user
@@ -272,6 +274,14 @@ func AllowedTier(tier string) bool {
return allowedTierRegex.MatchString(tier)
}
// AllowedPasswordHash checks if the given password hash is a valid bcrypt hash
func AllowedPasswordHash(hash string) error {
if !strings.HasPrefix(hash, "$2a$") && !strings.HasPrefix(hash, "$2b$") && !strings.HasPrefix(hash, "$2y$") {
return ErrPasswordHashInvalid
}
return nil
}
// Error constants used by the package
var (
ErrUnauthenticated = errors.New("unauthenticated")
@@ -279,6 +289,7 @@ var (
ErrInvalidArgument = errors.New("invalid argument")
ErrUserNotFound = errors.New("user not found")
ErrUserExists = errors.New("user already exists")
ErrPasswordHashInvalid = errors.New("password hash but be a bcrypt hash, use 'ntfy user hash' to generate")
ErrTierNotFound = errors.New("tier not found")
ErrTokenNotFound = errors.New("token not found")
ErrPhoneNumberNotFound = errors.New("phone number not found")

View File

@@ -120,6 +120,18 @@ func Filter[T any](slice []T, f func(T) bool) []T {
return result
}
// Find returns the first element in the slice that satisfies the given function, and a boolean indicating
// whether such an element was found. If no element is found, it returns the zero value of T and false.
func Find[T any](slice []T, f func(T) bool) (T, bool) {
for _, v := range slice {
if f(v) {
return v, true
}
}
var zero T
return zero, false
}
// RandomString returns a random string with a given length
func RandomString(length int) string {
return RandomStringPrefix("", length)