diff --git a/Makefile b/Makefile index 575bb788..df131c7a 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/cmd/access.go b/cmd/access.go index c6be94b5..51d367a3 100644 --- a/cmd/access.go +++ b/cmd/access.go @@ -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) } @@ -114,13 +116,13 @@ func changeAccess(c *cli.Context, manager *user.Manager, username string, topic return err } if permission.IsReadWrite() { - fmt.Fprintf(c.App.ErrWriter, "granted read-write access to topic %s\n\n", topic) + fmt.Fprintf(c.App.Writer, "granted read-write access to topic %s\n\n", topic) } else if permission.IsRead() { - fmt.Fprintf(c.App.ErrWriter, "granted read-only access to topic %s\n\n", topic) + fmt.Fprintf(c.App.Writer, "granted read-only access to topic %s\n\n", topic) } else if permission.IsWrite() { - fmt.Fprintf(c.App.ErrWriter, "granted write-only access to topic %s\n\n", topic) + fmt.Fprintf(c.App.Writer, "granted write-only access to topic %s\n\n", topic) } else { - fmt.Fprintf(c.App.ErrWriter, "revoked all access to topic %s\n\n", topic) + fmt.Fprintf(c.App.Writer, "revoked all access to topic %s\n\n", topic) } return showUserAccess(c, manager, username) } @@ -138,7 +140,7 @@ func resetAllAccess(c *cli.Context, manager *user.Manager) error { if err := manager.ResetAccess("", ""); err != nil { return err } - fmt.Fprintln(c.App.ErrWriter, "reset access for all users") + fmt.Fprintln(c.App.Writer, "reset access for all users") return nil } @@ -146,7 +148,7 @@ func resetUserAccess(c *cli.Context, manager *user.Manager, username string) err if err := manager.ResetAccess(username, ""); err != nil { return err } - fmt.Fprintf(c.App.ErrWriter, "reset access for user %s\n\n", username) + fmt.Fprintf(c.App.Writer, "reset access for user %s\n\n", username) return showUserAccess(c, manager, username) } @@ -154,7 +156,7 @@ func resetUserTopicAccess(c *cli.Context, manager *user.Manager, username string if err := manager.ResetAccess(username, topic); err != nil { return err } - fmt.Fprintf(c.App.ErrWriter, "reset access for user %s and topic %s\n\n", username, topic) + fmt.Fprintf(c.App.Writer, "reset access for user %s and topic %s\n\n", username, topic) return showUserAccess(c, manager, 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,34 +195,42 @@ 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 = ", server config" + } + fmt.Fprintf(c.App.Writer, "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") + fmt.Fprintf(c.App.Writer, "- 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 = " (server config)" + } + if grant.Permission.IsReadWrite() { + fmt.Fprintf(c.App.Writer, "- read-write access to topic %s%s\n", grant.TopicPattern, grantProvisioned) + } else if grant.Permission.IsRead() { + fmt.Fprintf(c.App.Writer, "- read-only access to topic %s%s\n", grant.TopicPattern, grantProvisioned) + } else if grant.Permission.IsWrite() { + fmt.Fprintf(c.App.Writer, "- 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.Writer, "- no access to topic %s%s\n", grant.TopicPattern, grantProvisioned) } } } else { - fmt.Fprintf(c.App.ErrWriter, "- no topic-specific permissions\n") + fmt.Fprintf(c.App.Writer, "- no topic-specific permissions\n") } if u.Name == user.Everyone { access := manager.DefaultAccess() if access.IsReadWrite() { - fmt.Fprintln(c.App.ErrWriter, "- read-write access to all (other) topics (server config)") + fmt.Fprintln(c.App.Writer, "- read-write access to all (other) topics (server config)") } else if access.IsRead() { - fmt.Fprintln(c.App.ErrWriter, "- read-only access to all (other) topics (server config)") + fmt.Fprintln(c.App.Writer, "- read-only access to all (other) topics (server config)") } else if access.IsWrite() { - fmt.Fprintln(c.App.ErrWriter, "- write-only access to all (other) topics (server config)") + fmt.Fprintln(c.App.Writer, "- write-only access to all (other) topics (server config)") } else { - fmt.Fprintln(c.App.ErrWriter, "- no access to any (other) topics (server config)") + fmt.Fprintln(c.App.Writer, "- no access to any (other) topics (server config)") } } } diff --git a/cmd/access_test.go b/cmd/access_test.go index 81c9f2b9..8810b6b3 100644 --- a/cmd/access_test.go +++ b/cmd/access_test.go @@ -13,9 +13,9 @@ func TestCLI_Access_Show(t *testing.T) { s, conf, port := newTestServerWithAuth(t) defer test.StopServer(t, s, port) - app, _, _, stderr := newTestApp() + app, _, stdout, _ := newTestApp() require.Nil(t, runAccessCommand(app, conf)) - require.Contains(t, stderr.String(), "user * (role: anonymous, tier: none)\n- no topic-specific permissions\n- no access to any (other) topics (server config)") + require.Contains(t, stdout.String(), "user * (role: anonymous, tier: none)\n- no topic-specific permissions\n- no access to any (other) topics (server config)") } func TestCLI_Access_Grant_And_Publish(t *testing.T) { @@ -30,7 +30,7 @@ func TestCLI_Access_Grant_And_Publish(t *testing.T) { require.Nil(t, runAccessCommand(app, conf, "ben", "sometopic", "read")) require.Nil(t, runAccessCommand(app, conf, "everyone", "announcements", "read")) - app, _, _, stderr := newTestApp() + app, _, stdout, _ := newTestApp() require.Nil(t, runAccessCommand(app, conf)) expected := `user phil (role: admin, tier: none) - read-write access to all topics (admin role) @@ -41,7 +41,7 @@ user * (role: anonymous, tier: none) - read-only access to topic announcements - no access to any (other) topics (server config) ` - require.Equal(t, expected, stderr.String()) + require.Equal(t, expected, stdout.String()) // See if access permissions match app, _, _, _ = newTestApp() diff --git a/cmd/serve.go b/cmd/serve.go index f894fe65..33d0ed78 100644 --- a/cmd/serve.go +++ b/cmd/serve.go @@ -48,6 +48,9 @@ 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-users", Aliases: []string{"auth_users"}, EnvVars: []string{"NTFY_AUTH_USERS"}, Usage: "pre-provisioned declarative users"}), + altsrc.NewStringSliceFlag(&cli.StringSliceFlag{Name: "auth-access", Aliases: []string{"auth_access"}, EnvVars: []string{"NTFY_AUTH_ACCESS"}, Usage: "pre-provisioned declarative access control entries"}), + altsrc.NewStringSliceFlag(&cli.StringSliceFlag{Name: "auth-tokens", Aliases: []string{"auth_tokens"}, EnvVars: []string{"NTFY_AUTH_TOKENS"}, Usage: "pre-provisioned declarative access tokens"}), 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 +157,9 @@ func execServe(c *cli.Context) error { authFile := c.String("auth-file") authStartupQueries := c.String("auth-startup-queries") authDefaultAccess := c.String("auth-default-access") + authUsersRaw := c.StringSlice("auth-users") + authAccessRaw := c.StringSlice("auth-access") + authTokensRaw := c.StringSlice("auth-tokens") attachmentCacheDir := c.String("attachment-cache-dir") attachmentTotalSizeLimitStr := c.String("attachment-total-size-limit") attachmentFileSizeLimitStr := c.String("attachment-file-size-limit") @@ -344,11 +350,23 @@ 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'") } + authUsers, err := parseUsers(authUsersRaw) + if err != nil { + return err + } + authAccess, err := parseAccess(authUsers, authAccessRaw) + if err != nil { + return err + } + authTokens, err := parseTokens(authUsers, authTokensRaw) + if err != nil { + return err + } // Special case: Unset default if listenHTTP == "-" { @@ -404,6 +422,9 @@ func execServe(c *cli.Context) error { conf.AuthFile = authFile conf.AuthStartupQueries = authStartupQueries conf.AuthDefault = authDefault + conf.AuthUsers = authUsers + conf.AuthAccess = authAccess + conf.AuthTokens = authTokens conf.AttachmentCacheDir = attachmentCacheDir conf.AttachmentTotalSizeLimit = attachmentTotalSizeLimit conf.AttachmentFileSizeLimit = attachmentFileSizeLimit @@ -517,6 +538,112 @@ func parseIPHostPrefix(host string) (prefixes []netip.Prefix, err error) { return } +func parseUsers(usersRaw []string) ([]*user.User, error) { + users := make([]*user.User, 0) + for _, userLine := range usersRaw { + parts := strings.Split(userLine, ":") + if len(parts) != 3 { + return nil, fmt.Errorf("invalid auth-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-users: %s, username invalid", userLine) + } else if err := user.ValidPasswordHash(passwordHash); err != nil { + return nil, fmt.Errorf("invalid auth-users: %s, %s", userLine, err.Error()) + } else if !user.AllowedRole(role) { + return nil, fmt.Errorf("invalid auth-users: %s, role %s is not allowed, allowed roles are 'admin' or 'user'", userLine, role) + } + users = append(users, &user.User{ + Name: username, + Hash: passwordHash, + Role: role, + Provisioned: true, + }) + } + return users, nil +} + +func parseAccess(users []*user.User, accessRaw []string) (map[string][]*user.Grant, error) { + access := make(map[string][]*user.Grant) + for _, accessLine := range accessRaw { + parts := strings.Split(accessLine, ":") + if len(parts) != 3 { + return nil, fmt.Errorf("invalid auth-access: %s, expected format: 'user:topic:permission'", accessLine) + } + username := strings.TrimSpace(parts[0]) + if username == userEveryone { + username = user.Everyone + } + u, exists := util.Find(users, func(u *user.User) bool { + return u.Name == username + }) + if username != user.Everyone { + if !exists { + return nil, fmt.Errorf("invalid auth-access: %s, user %s is not provisioned", accessLine, username) + } else if !user.AllowedUsername(username) { + return nil, fmt.Errorf("invalid auth-access: %s, username %s invalid", accessLine, username) + } else if u.Role != user.RoleUser { + return nil, fmt.Errorf("invalid auth-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-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-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 parseTokens(users []*user.User, tokensRaw []string) (map[string][]*user.Token, error) { + tokens := make(map[string][]*user.Token) + for _, tokenLine := range tokensRaw { + parts := strings.Split(tokenLine, ":") + if len(parts) < 2 || len(parts) > 3 { + return nil, fmt.Errorf("invalid auth-tokens: %s, expected format: 'user:token[:label]'", tokenLine) + } + username := strings.TrimSpace(parts[0]) + _, exists := util.Find(users, func(u *user.User) bool { + return u.Name == username + }) + if !exists { + return nil, fmt.Errorf("invalid auth-tokens: %s, user %s is not provisioned", tokenLine, username) + } else if !user.AllowedUsername(username) { + return nil, fmt.Errorf("invalid auth-tokens: %s, username %s invalid", tokenLine, username) + } + token := strings.TrimSpace(parts[1]) + if !user.ValidToken(token) { + return nil, fmt.Errorf("invalid auth-tokens: %s, token %s invalid, use 'ntfy token generate' to generate a random token", tokenLine, token) + } + var label string + if len(parts) > 2 { + label = parts[2] + } + if _, exists := tokens[username]; !exists { + tokens[username] = make([]*user.Token, 0) + } + tokens[username] = append(tokens[username], &user.Token{ + Value: token, + Label: label, + Provisioned: true, + }) + } + return tokens, nil +} + func reloadLogLevel(inputSource altsrc.InputSourceContext) error { newLevelStr, err := inputSource.String("log-level") if err != nil { diff --git a/cmd/serve_test.go b/cmd/serve_test.go index 748adbd8..339423b6 100644 --- a/cmd/serve_test.go +++ b/cmd/serve_test.go @@ -14,9 +14,461 @@ import ( "github.com/stretchr/testify/require" "heckel.io/ntfy/v2/client" "heckel.io/ntfy/v2/test" + "heckel.io/ntfy/v2/user" "heckel.io/ntfy/v2/util" ) +func TestParseUsers_Success(t *testing.T) { + tests := []struct { + name string + input []string + expected []*user.User + }{ + { + name: "single user", + input: []string{"alice:$2a$10$abcdefghijklmnopqrstuvwxyz:user"}, + expected: []*user.User{ + { + Name: "alice", + Hash: "$2a$10$abcdefghijklmnopqrstuvwxyz", + Role: user.RoleUser, + Provisioned: true, + }, + }, + }, + { + name: "multiple users with different roles", + input: []string{ + "alice:$2a$10$abcdefghijklmnopqrstuvwxyz:user", + "bob:$2b$10$abcdefghijklmnopqrstuvwxyz:admin", + }, + expected: []*user.User{ + { + Name: "alice", + Hash: "$2a$10$abcdefghijklmnopqrstuvwxyz", + Role: user.RoleUser, + Provisioned: true, + }, + { + Name: "bob", + Hash: "$2b$10$abcdefghijklmnopqrstuvwxyz", + Role: user.RoleAdmin, + Provisioned: true, + }, + }, + }, + { + name: "empty input", + input: []string{}, + expected: []*user.User{}, + }, + { + name: "user with special characters in name", + input: []string{"alice.test+123@example.com:$2y$10$abcdefghijklmnopqrstuvwxyz:user"}, + expected: []*user.User{ + { + Name: "alice.test+123@example.com", + Hash: "$2y$10$abcdefghijklmnopqrstuvwxyz", + Role: user.RoleUser, + Provisioned: true, + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := parseUsers(tt.input) + require.NoError(t, err) + require.Len(t, result, len(tt.expected)) + + for i, expectedUser := range tt.expected { + assert.Equal(t, expectedUser.Name, result[i].Name) + assert.Equal(t, expectedUser.Hash, result[i].Hash) + assert.Equal(t, expectedUser.Role, result[i].Role) + assert.Equal(t, expectedUser.Provisioned, result[i].Provisioned) + } + }) + } +} + +func TestParseUsers_Errors(t *testing.T) { + tests := []struct { + name string + input []string + error string + }{ + { + name: "invalid format - too few parts", + input: []string{"alice:hash"}, + error: "invalid auth-users: alice:hash, expected format: 'name:hash:role'", + }, + { + name: "invalid format - too many parts", + input: []string{"alice:hash:role:extra"}, + error: "invalid auth-users: alice:hash:role:extra, expected format: 'name:hash:role'", + }, + { + name: "invalid username", + input: []string{"alice@#$%:$2a$10$abcdefghijklmnopqrstuvwxyz:user"}, + error: "invalid auth-users: alice@#$%:$2a$10$abcdefghijklmnopqrstuvwxyz:user, username invalid", + }, + { + name: "invalid password hash - wrong prefix", + input: []string{"alice:plaintext:user"}, + error: "invalid auth-users: alice:plaintext:user, password hash but be a bcrypt hash, use 'ntfy user hash' to generate", + }, + { + name: "invalid role", + input: []string{"alice:$2a$10$abcdefghijklmnopqrstuvwxyz:invalid"}, + error: "invalid auth-users: alice:$2a$10$abcdefghijklmnopqrstuvwxyz:invalid, role invalid is not allowed, allowed roles are 'admin' or 'user'", + }, + { + name: "empty username", + input: []string{":$2a$10$abcdefghijklmnopqrstuvwxyz:user"}, + error: "invalid auth-users: :$2a$10$abcdefghijklmnopqrstuvwxyz:user, username invalid", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := parseUsers(tt.input) + require.Error(t, err) + require.Nil(t, result) + assert.Contains(t, err.Error(), tt.error) + }) + } +} + +func TestParseAccess_Success(t *testing.T) { + users := []*user.User{ + {Name: "alice", Role: user.RoleUser}, + {Name: "bob", Role: user.RoleUser}, + } + + tests := []struct { + name string + users []*user.User + input []string + expected map[string][]*user.Grant + }{ + { + name: "single access entry", + users: users, + input: []string{"alice:mytopic:read-write"}, + expected: map[string][]*user.Grant{ + "alice": { + { + TopicPattern: "mytopic", + Permission: user.PermissionReadWrite, + Provisioned: true, + }, + }, + }, + }, + { + name: "multiple access entries for same user", + users: users, + input: []string{ + "alice:topic1:read-only", + "alice:topic2:write-only", + }, + expected: map[string][]*user.Grant{ + "alice": { + { + TopicPattern: "topic1", + Permission: user.PermissionRead, + Provisioned: true, + }, + { + TopicPattern: "topic2", + Permission: user.PermissionWrite, + Provisioned: true, + }, + }, + }, + }, + { + name: "access for everyone", + users: users, + input: []string{"everyone:publictopic:read-only"}, + expected: map[string][]*user.Grant{ + user.Everyone: { + { + TopicPattern: "publictopic", + Permission: user.PermissionRead, + Provisioned: true, + }, + }, + }, + }, + { + name: "wildcard topic pattern", + users: users, + input: []string{"alice:topic*:read-write"}, + expected: map[string][]*user.Grant{ + "alice": { + { + TopicPattern: "topic*", + Permission: user.PermissionReadWrite, + Provisioned: true, + }, + }, + }, + }, + { + name: "empty input", + users: users, + input: []string{}, + expected: map[string][]*user.Grant{}, + }, + { + name: "deny-all permission", + users: users, + input: []string{"alice:secretopic:deny-all"}, + expected: map[string][]*user.Grant{ + "alice": { + { + TopicPattern: "secretopic", + Permission: user.PermissionDenyAll, + Provisioned: true, + }, + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := parseAccess(tt.users, tt.input) + require.NoError(t, err) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestParseAccess_Errors(t *testing.T) { + users := []*user.User{ + {Name: "alice", Role: user.RoleUser}, + {Name: "admin", Role: user.RoleAdmin}, + } + + tests := []struct { + name string + users []*user.User + input []string + error string + }{ + { + name: "invalid format - too few parts", + users: users, + input: []string{"alice:topic"}, + error: "invalid auth-access: alice:topic, expected format: 'user:topic:permission'", + }, + { + name: "invalid format - too many parts", + users: users, + input: []string{"alice:topic:read:extra"}, + error: "invalid auth-access: alice:topic:read:extra, expected format: 'user:topic:permission'", + }, + { + name: "user not provisioned", + users: users, + input: []string{"charlie:topic:read"}, + error: "invalid auth-access: charlie:topic:read, user charlie is not provisioned", + }, + { + name: "admin user cannot have ACL entries", + users: users, + input: []string{"admin:topic:read"}, + error: "invalid auth-access: admin:topic:read, user admin is not a regular user, only regular users can have ACL entries", + }, + { + name: "invalid topic pattern", + users: users, + input: []string{"alice:topic-with-invalid-chars!:read"}, + error: "invalid auth-access: alice:topic-with-invalid-chars!:read, topic pattern topic-with-invalid-chars! invalid", + }, + { + name: "invalid permission", + users: users, + input: []string{"alice:topic:invalid-permission"}, + error: "invalid auth-access: alice:topic:invalid-permission, permission invalid-permission invalid", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := parseAccess(tt.users, tt.input) + require.Error(t, err) + require.Nil(t, result) + assert.Contains(t, err.Error(), tt.error) + }) + } +} + +func TestParseTokens_Success(t *testing.T) { + users := []*user.User{ + {Name: "alice"}, + {Name: "bob"}, + } + + tests := []struct { + name string + users []*user.User + input []string + expected map[string][]*user.Token + }{ + { + name: "single token without label", + users: users, + input: []string{"alice:tk_abcdefghijklmnopqrstuvwxyz123"}, + expected: map[string][]*user.Token{ + "alice": { + { + Value: "tk_abcdefghijklmnopqrstuvwxyz123", + Label: "", + Provisioned: true, + }, + }, + }, + }, + { + name: "single token with label", + users: users, + input: []string{"alice:tk_abcdefghijklmnopqrstuvwxyz123:My Phone"}, + expected: map[string][]*user.Token{ + "alice": { + { + Value: "tk_abcdefghijklmnopqrstuvwxyz123", + Label: "My Phone", + Provisioned: true, + }, + }, + }, + }, + { + name: "multiple tokens for same user", + users: users, + input: []string{ + "alice:tk_abcdefghijklmnopqrstuvwxyz123:Phone", + "alice:tk_zyxwvutsrqponmlkjihgfedcba987:Laptop", + }, + expected: map[string][]*user.Token{ + "alice": { + { + Value: "tk_abcdefghijklmnopqrstuvwxyz123", + Label: "Phone", + Provisioned: true, + }, + { + Value: "tk_zyxwvutsrqponmlkjihgfedcba987", + Label: "Laptop", + Provisioned: true, + }, + }, + }, + }, + { + name: "tokens for multiple users", + users: users, + input: []string{ + "alice:tk_abcdefghijklmnopqrstuvwxyz123:Phone", + "bob:tk_zyxwvutsrqponmlkjihgfedcba987:Tablet", + }, + expected: map[string][]*user.Token{ + "alice": { + { + Value: "tk_abcdefghijklmnopqrstuvwxyz123", + Label: "Phone", + Provisioned: true, + }, + }, + "bob": { + { + Value: "tk_zyxwvutsrqponmlkjihgfedcba987", + Label: "Tablet", + Provisioned: true, + }, + }, + }, + }, + { + name: "empty input", + users: users, + input: []string{}, + expected: map[string][]*user.Token{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := parseTokens(tt.users, tt.input) + require.NoError(t, err) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestParseTokens_Errors(t *testing.T) { + users := []*user.User{ + {Name: "alice"}, + } + + tests := []struct { + name string + users []*user.User + input []string + error string + }{ + { + name: "invalid format - too few parts", + users: users, + input: []string{"alice"}, + error: "invalid auth-tokens: alice, expected format: 'user:token[:label]'", + }, + { + name: "invalid format - too many parts", + users: users, + input: []string{"alice:token:label:extra:parts"}, + error: "invalid auth-tokens: alice:token:label:extra:parts, expected format: 'user:token[:label]'", + }, + { + name: "user not provisioned", + users: users, + input: []string{"charlie:tk_abcdefghijklmnopqrstuvwxyz123"}, + error: "invalid auth-tokens: charlie:tk_abcdefghijklmnopqrstuvwxyz123, user charlie is not provisioned", + }, + { + name: "invalid token format", + users: users, + input: []string{"alice:invalid-token"}, + error: "invalid auth-tokens: alice:invalid-token, token invalid-token invalid, use 'ntfy token generate' to generate a random token", + }, + { + name: "token too short", + users: users, + input: []string{"alice:tk_short"}, + error: "invalid auth-tokens: alice:tk_short, token tk_short invalid, use 'ntfy token generate' to generate a random token", + }, + { + name: "token without prefix", + users: users, + input: []string{"alice:abcdefghijklmnopqrstuvwxyz12345"}, + error: "invalid auth-tokens: alice:abcdefghijklmnopqrstuvwxyz12345, token abcdefghijklmnopqrstuvwxyz12345 invalid, use 'ntfy token generate' to generate a random token", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := parseTokens(tt.users, tt.input) + require.Error(t, err) + require.Nil(t, result) + assert.Contains(t, err.Error(), tt.error) + }) + } +} + func TestCLI_Serve_Unix_Curl(t *testing.T) { sockFile := filepath.Join(t.TempDir(), "ntfy.sock") configFile := newEmptyFile(t) // Avoid issues with existing server.yml file on system diff --git a/cmd/tier.go b/cmd/tier.go index 3b45eaa7..de34576e 100644 --- a/cmd/tier.go +++ b/cmd/tier.go @@ -182,7 +182,7 @@ func execTierAdd(c *cli.Context) error { } if tier, _ := manager.Tier(code); tier != nil { if c.Bool("ignore-exists") { - fmt.Fprintf(c.App.ErrWriter, "tier %s already exists (exited successfully)\n", code) + fmt.Fprintf(c.App.Writer, "tier %s already exists (exited successfully)\n", code) return nil } return fmt.Errorf("tier %s already exists", code) @@ -234,7 +234,7 @@ func execTierAdd(c *cli.Context) error { if err != nil { return err } - fmt.Fprintf(c.App.ErrWriter, "tier added\n\n") + fmt.Fprintf(c.App.Writer, "tier added\n\n") printTier(c, tier) return nil } @@ -315,7 +315,7 @@ func execTierChange(c *cli.Context) error { if err := manager.UpdateTier(tier); err != nil { return err } - fmt.Fprintf(c.App.ErrWriter, "tier updated\n\n") + fmt.Fprintf(c.App.Writer, "tier updated\n\n") printTier(c, tier) return nil } @@ -335,7 +335,7 @@ func execTierDel(c *cli.Context) error { if err := manager.RemoveTier(code); err != nil { return err } - fmt.Fprintf(c.App.ErrWriter, "tier %s removed\n", code) + fmt.Fprintf(c.App.Writer, "tier %s removed\n", code) return nil } @@ -359,16 +359,16 @@ func printTier(c *cli.Context, tier *user.Tier) { if tier.StripeMonthlyPriceID != "" && tier.StripeYearlyPriceID != "" { prices = fmt.Sprintf("%s / %s", tier.StripeMonthlyPriceID, tier.StripeYearlyPriceID) } - fmt.Fprintf(c.App.ErrWriter, "tier %s (id: %s)\n", tier.Code, tier.ID) - fmt.Fprintf(c.App.ErrWriter, "- Name: %s\n", tier.Name) - fmt.Fprintf(c.App.ErrWriter, "- Message limit: %d\n", tier.MessageLimit) - fmt.Fprintf(c.App.ErrWriter, "- Message expiry duration: %s (%d seconds)\n", tier.MessageExpiryDuration.String(), int64(tier.MessageExpiryDuration.Seconds())) - fmt.Fprintf(c.App.ErrWriter, "- Email limit: %d\n", tier.EmailLimit) - fmt.Fprintf(c.App.ErrWriter, "- Phone call limit: %d\n", tier.CallLimit) - fmt.Fprintf(c.App.ErrWriter, "- Reservation limit: %d\n", tier.ReservationLimit) - fmt.Fprintf(c.App.ErrWriter, "- Attachment file size limit: %s\n", util.FormatSizeHuman(tier.AttachmentFileSizeLimit)) - fmt.Fprintf(c.App.ErrWriter, "- Attachment total size limit: %s\n", util.FormatSizeHuman(tier.AttachmentTotalSizeLimit)) - fmt.Fprintf(c.App.ErrWriter, "- Attachment expiry duration: %s (%d seconds)\n", tier.AttachmentExpiryDuration.String(), int64(tier.AttachmentExpiryDuration.Seconds())) - fmt.Fprintf(c.App.ErrWriter, "- Attachment daily bandwidth limit: %s\n", util.FormatSizeHuman(tier.AttachmentBandwidthLimit)) - fmt.Fprintf(c.App.ErrWriter, "- Stripe prices (monthly/yearly): %s\n", prices) + fmt.Fprintf(c.App.Writer, "tier %s (id: %s)\n", tier.Code, tier.ID) + fmt.Fprintf(c.App.Writer, "- Name: %s\n", tier.Name) + fmt.Fprintf(c.App.Writer, "- Message limit: %d\n", tier.MessageLimit) + fmt.Fprintf(c.App.Writer, "- Message expiry duration: %s (%d seconds)\n", tier.MessageExpiryDuration.String(), int64(tier.MessageExpiryDuration.Seconds())) + fmt.Fprintf(c.App.Writer, "- Email limit: %d\n", tier.EmailLimit) + fmt.Fprintf(c.App.Writer, "- Phone call limit: %d\n", tier.CallLimit) + fmt.Fprintf(c.App.Writer, "- Reservation limit: %d\n", tier.ReservationLimit) + fmt.Fprintf(c.App.Writer, "- Attachment file size limit: %s\n", util.FormatSizeHuman(tier.AttachmentFileSizeLimit)) + fmt.Fprintf(c.App.Writer, "- Attachment total size limit: %s\n", util.FormatSizeHuman(tier.AttachmentTotalSizeLimit)) + fmt.Fprintf(c.App.Writer, "- Attachment expiry duration: %s (%d seconds)\n", tier.AttachmentExpiryDuration.String(), int64(tier.AttachmentExpiryDuration.Seconds())) + fmt.Fprintf(c.App.Writer, "- Attachment daily bandwidth limit: %s\n", util.FormatSizeHuman(tier.AttachmentBandwidthLimit)) + fmt.Fprintf(c.App.Writer, "- Stripe prices (monthly/yearly): %s\n", prices) } diff --git a/cmd/tier_test.go b/cmd/tier_test.go index 145f273e..8ca2b768 100644 --- a/cmd/tier_test.go +++ b/cmd/tier_test.go @@ -12,21 +12,21 @@ func TestCLI_Tier_AddListChangeDelete(t *testing.T) { s, conf, port := newTestServerWithAuth(t) defer test.StopServer(t, s, port) - app, _, _, stderr := newTestApp() + app, _, stdout, _ := newTestApp() require.Nil(t, runTierCommand(app, conf, "add", "--name", "Pro", "--message-limit", "1234", "pro")) - require.Contains(t, stderr.String(), "tier added\n\ntier pro (id: ti_") + require.Contains(t, stdout.String(), "tier added\n\ntier pro (id: ti_") err := runTierCommand(app, conf, "add", "pro") require.NotNil(t, err) require.Equal(t, "tier pro already exists", err.Error()) - app, _, _, stderr = newTestApp() + app, _, stdout, _ = newTestApp() require.Nil(t, runTierCommand(app, conf, "list")) - require.Contains(t, stderr.String(), "tier pro (id: ti_") - require.Contains(t, stderr.String(), "- Name: Pro") - require.Contains(t, stderr.String(), "- Message limit: 1234") + require.Contains(t, stdout.String(), "tier pro (id: ti_") + require.Contains(t, stdout.String(), "- Name: Pro") + require.Contains(t, stdout.String(), "- Message limit: 1234") - app, _, _, stderr = newTestApp() + app, _, stdout, _ = newTestApp() require.Nil(t, runTierCommand(app, conf, "change", "--message-limit=999", "--message-expiry-duration=2d", @@ -40,18 +40,18 @@ func TestCLI_Tier_AddListChangeDelete(t *testing.T) { "--stripe-yearly-price-id=price_992", "pro", )) - require.Contains(t, stderr.String(), "- Message limit: 999") - require.Contains(t, stderr.String(), "- Message expiry duration: 48h") - require.Contains(t, stderr.String(), "- Email limit: 91") - require.Contains(t, stderr.String(), "- Reservation limit: 98") - require.Contains(t, stderr.String(), "- Attachment file size limit: 100.0 MB") - require.Contains(t, stderr.String(), "- Attachment expiry duration: 24h") - require.Contains(t, stderr.String(), "- Attachment total size limit: 10.0 GB") - require.Contains(t, stderr.String(), "- Stripe prices (monthly/yearly): price_991 / price_992") + require.Contains(t, stdout.String(), "- Message limit: 999") + require.Contains(t, stdout.String(), "- Message expiry duration: 48h") + require.Contains(t, stdout.String(), "- Email limit: 91") + require.Contains(t, stdout.String(), "- Reservation limit: 98") + require.Contains(t, stdout.String(), "- Attachment file size limit: 100.0 MB") + require.Contains(t, stdout.String(), "- Attachment expiry duration: 24h") + require.Contains(t, stdout.String(), "- Attachment total size limit: 10.0 GB") + require.Contains(t, stdout.String(), "- Stripe prices (monthly/yearly): price_991 / price_992") - app, _, _, stderr = newTestApp() + app, _, stdout, _ = newTestApp() require.Nil(t, runTierCommand(app, conf, "remove", "pro")) - require.Contains(t, stderr.String(), "tier pro removed") + require.Contains(t, stdout.String(), "tier pro removed") } func runTierCommand(app *cli.App, conf *server.Config, args ...string) error { diff --git a/cmd/token.go b/cmd/token.go index cb92a130..b0393b88 100644 --- a/cmd/token.go +++ b/cmd/token.go @@ -72,6 +72,15 @@ Example: This is a server-only command. It directly reads from user.db as defined in the server config file server.yml. The command only works if 'auth-file' is properly defined.`, }, + { + Name: "generate", + Usage: "Generates a random token", + Action: execTokenGenerate, + Description: `Randomly generate a token to be used in provisioned tokens. + +This command only generates the token value, but does not persist it anywhere. +The output can be used in the 'auth-tokens' config option.`, + }, }, Description: `Manage access tokens for individual users. @@ -112,19 +121,19 @@ func execTokenAdd(c *cli.Context) error { 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 } - token, err := manager.CreateToken(u.ID, label, expires, netip.IPv4Unspecified()) + token, err := manager.CreateToken(u.ID, label, expires, netip.IPv4Unspecified(), false) if err != nil { return err } if expires.Unix() == 0 { - fmt.Fprintf(c.App.ErrWriter, "token %s created for user %s, never expires\n", token.Value, u.Name) + fmt.Fprintf(c.App.Writer, "token %s created for user %s, never expires\n", token.Value, u.Name) } else { - fmt.Fprintf(c.App.ErrWriter, "token %s created for user %s, expires %v\n", token.Value, u.Name, expires.Format(time.UnixDate)) + fmt.Fprintf(c.App.Writer, "token %s created for user %s, expires %v\n", token.Value, u.Name, expires.Format(time.UnixDate)) } return nil } @@ -141,7 +150,7 @@ func execTokenDel(c *cli.Context) error { 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 @@ -149,7 +158,7 @@ func execTokenDel(c *cli.Context) error { if err := manager.RemoveToken(u.ID, token); err != nil { return err } - fmt.Fprintf(c.App.ErrWriter, "token %s for user %s removed\n", token, username) + fmt.Fprintf(c.App.Writer, "token %s for user %s removed\n", token, username) return nil } @@ -165,7 +174,7 @@ func execTokenList(c *cli.Context) error { var users []*user.User if username != "" { 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 @@ -183,15 +192,15 @@ func execTokenList(c *cli.Context) error { if err != nil { return err } else if len(tokens) == 0 && username != "" { - fmt.Fprintf(c.App.ErrWriter, "user %s has no access tokens\n", username) + fmt.Fprintf(c.App.Writer, "user %s has no access tokens\n", username) return nil } else if len(tokens) == 0 { continue } usersWithTokens++ - fmt.Fprintf(c.App.ErrWriter, "user %s\n", u.Name) + fmt.Fprintf(c.App.Writer, "user %s\n", u.Name) for _, t := range tokens { - var label, expires string + var label, expires, provisioned string if t.Label != "" { label = fmt.Sprintf(" (%s)", t.Label) } @@ -200,11 +209,19 @@ func execTokenList(c *cli.Context) error { } else { expires = fmt.Sprintf("expires %s", t.Expires.Format(time.RFC822)) } - fmt.Fprintf(c.App.ErrWriter, "- %s%s, %s, accessed from %s at %s\n", t.Value, label, expires, t.LastOrigin.String(), t.LastAccess.Format(time.RFC822)) + if t.Provisioned { + provisioned = " (server config)" + } + fmt.Fprintf(c.App.Writer, "- %s%s, %s, accessed from %s at %s%s\n", t.Value, label, expires, t.LastOrigin.String(), t.LastAccess.Format(time.RFC822), provisioned) } } if usersWithTokens == 0 { - fmt.Fprintf(c.App.ErrWriter, "no users with tokens\n") + fmt.Fprintf(c.App.Writer, "no users with tokens\n") } return nil } + +func execTokenGenerate(c *cli.Context) error { + fmt.Fprintln(c.App.Writer, user.GenerateToken()) + return nil +} diff --git a/cmd/token_test.go b/cmd/token_test.go index 03295081..456e53cd 100644 --- a/cmd/token_test.go +++ b/cmd/token_test.go @@ -14,28 +14,28 @@ func TestCLI_Token_AddListRemove(t *testing.T) { s, conf, port := newTestServerWithAuth(t) defer test.StopServer(t, s, port) - app, stdin, _, stderr := newTestApp() + app, stdin, stdout, _ := newTestApp() stdin.WriteString("mypass\nmypass") require.Nil(t, runUserCommand(app, conf, "add", "phil")) - require.Contains(t, stderr.String(), "user phil added with role user") + require.Contains(t, stdout.String(), "user phil added with role user") - app, _, _, stderr = newTestApp() + app, _, stdout, _ = newTestApp() require.Nil(t, runTokenCommand(app, conf, "add", "phil")) - require.Regexp(t, `token tk_.+ created for user phil, never expires`, stderr.String()) + require.Regexp(t, `token tk_.+ created for user phil, never expires`, stdout.String()) - app, _, _, stderr = newTestApp() + app, _, stdout, _ = newTestApp() require.Nil(t, runTokenCommand(app, conf, "list", "phil")) - require.Regexp(t, `user phil\n- tk_.+, never expires, accessed from 0.0.0.0 at .+`, stderr.String()) + require.Regexp(t, `user phil\n- tk_.+, never expires, accessed from 0.0.0.0 at .+`, stdout.String()) re := regexp.MustCompile(`tk_\w+`) - token := re.FindString(stderr.String()) + token := re.FindString(stdout.String()) - app, _, _, stderr = newTestApp() + app, _, stdout, _ = newTestApp() require.Nil(t, runTokenCommand(app, conf, "remove", "phil", token)) - require.Regexp(t, fmt.Sprintf("token %s for user phil removed", token), stderr.String()) + require.Regexp(t, fmt.Sprintf("token %s for user phil removed", token), stdout.String()) - app, _, _, stderr = newTestApp() + app, _, stdout, _ = newTestApp() require.Nil(t, runTokenCommand(app, conf, "list")) - require.Equal(t, "no users with tokens\n", stderr.String()) + require.Equal(t, "no users with tokens\n", stdout.String()) } func runTokenCommand(app *cli.App, conf *server.Config, args ...string) error { diff --git a/cmd/user.go b/cmd/user.go index 0ee45bc3..6bf7030e 100644 --- a/cmd/user.go +++ b/cmd/user.go @@ -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-users. + +Example: + $ ntfy user hash + (asks for password and confirmation) + $2a$10$YLiO8U21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C `, }, { @@ -196,7 +211,7 @@ func execUserAdd(c *cli.Context) error { } if user, _ := manager.User(username); user != nil { if c.Bool("ignore-exists") { - fmt.Fprintf(c.App.ErrWriter, "user %s already exists (exited successfully)\n", username) + fmt.Fprintf(c.App.Writer, "user %s already exists (exited successfully)\n", username) return nil } return fmt.Errorf("user %s already exists", username) @@ -211,7 +226,7 @@ func execUserAdd(c *cli.Context) error { if err := manager.AddUser(username, password, role, hashed); err != nil { return err } - fmt.Fprintf(c.App.ErrWriter, "user %s added with role %s\n", username, role) + fmt.Fprintf(c.App.Writer, "user %s added with role %s\n", username, role) return nil } @@ -226,13 +241,13 @@ 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 { return err } - fmt.Fprintf(c.App.ErrWriter, "user %s removed\n", username) + fmt.Fprintf(c.App.Writer, "user %s removed\n", username) return 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 == "" { @@ -264,7 +279,7 @@ func execUserChangePass(c *cli.Context) error { if err := manager.ChangePassword(username, password, hashed); err != nil { return err } - fmt.Fprintf(c.App.ErrWriter, "changed password for user %s\n", username) + fmt.Fprintf(c.App.Writer, "changed password for user %s\n", username) return nil } @@ -280,13 +295,26 @@ 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 { return err } - fmt.Fprintf(c.App.ErrWriter, "changed role for user %s to %s\n", username, role) + fmt.Fprintf(c.App.Writer, "changed role for user %s to %s\n", username, role) + return nil +} + +func execUserHash(c *cli.Context) error { + password, err := readPasswordAndConfirm(c) + if err != nil { + return err + } + hash, err := user.HashPassword(password) + if err != nil { + return fmt.Errorf("failed to hash password: %w", err) + } + fmt.Fprintln(c.App.Writer, hash) return nil } @@ -304,19 +332,19 @@ 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 { if err := manager.ResetTier(username); err != nil { return err } - fmt.Fprintf(c.App.ErrWriter, "removed tier from user %s\n", username) + fmt.Fprintf(c.App.Writer, "removed tier from user %s\n", username) } else { if err := manager.ChangeTier(username, tier); err != nil { return err } - fmt.Fprintf(c.App.ErrWriter, "changed tier for user %s to %s\n", username, tier) + fmt.Fprintf(c.App.Writer, "changed tier for user %s to %s\n", username, tier) } return nil } @@ -346,7 +374,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, // Hack: 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) { diff --git a/cmd/user_test.go b/cmd/user_test.go index e1bdd3ab..7a1d5378 100644 --- a/cmd/user_test.go +++ b/cmd/user_test.go @@ -15,20 +15,20 @@ func TestCLI_User_Add(t *testing.T) { s, conf, port := newTestServerWithAuth(t) defer test.StopServer(t, s, port) - app, stdin, _, stderr := newTestApp() + app, stdin, stdout, _ := newTestApp() stdin.WriteString("mypass\nmypass") require.Nil(t, runUserCommand(app, conf, "add", "phil")) - require.Contains(t, stderr.String(), "user phil added with role user") + require.Contains(t, stdout.String(), "user phil added with role user") } func TestCLI_User_Add_Exists(t *testing.T) { s, conf, port := newTestServerWithAuth(t) defer test.StopServer(t, s, port) - app, stdin, _, stderr := newTestApp() + app, stdin, stdout, _ := newTestApp() stdin.WriteString("mypass\nmypass") require.Nil(t, runUserCommand(app, conf, "add", "phil")) - require.Contains(t, stderr.String(), "user phil added with role user") + require.Contains(t, stdout.String(), "user phil added with role user") app, stdin, _, _ = newTestApp() stdin.WriteString("mypass\nmypass") @@ -41,10 +41,10 @@ func TestCLI_User_Add_Admin(t *testing.T) { s, conf, port := newTestServerWithAuth(t) defer test.StopServer(t, s, port) - app, stdin, _, stderr := newTestApp() + app, stdin, stdout, _ := newTestApp() stdin.WriteString("mypass\nmypass") require.Nil(t, runUserCommand(app, conf, "add", "--role=admin", "phil")) - require.Contains(t, stderr.String(), "user phil added with role admin") + require.Contains(t, stdout.String(), "user phil added with role admin") } func TestCLI_User_Add_Password_Mismatch(t *testing.T) { @@ -63,16 +63,16 @@ func TestCLI_User_ChangePass(t *testing.T) { defer test.StopServer(t, s, port) // Add user - app, stdin, _, stderr := newTestApp() + app, stdin, stdout, _ := newTestApp() stdin.WriteString("mypass\nmypass") require.Nil(t, runUserCommand(app, conf, "add", "phil")) - require.Contains(t, stderr.String(), "user phil added with role user") + require.Contains(t, stdout.String(), "user phil added with role user") // Change pass - app, stdin, _, stderr = newTestApp() + app, stdin, stdout, _ = newTestApp() stdin.WriteString("newpass\nnewpass") require.Nil(t, runUserCommand(app, conf, "change-pass", "phil")) - require.Contains(t, stderr.String(), "changed password for user phil") + require.Contains(t, stdout.String(), "changed password for user phil") } func TestCLI_User_ChangeRole(t *testing.T) { @@ -80,15 +80,15 @@ func TestCLI_User_ChangeRole(t *testing.T) { defer test.StopServer(t, s, port) // Add user - app, stdin, _, stderr := newTestApp() + app, stdin, stdout, _ := newTestApp() stdin.WriteString("mypass\nmypass") require.Nil(t, runUserCommand(app, conf, "add", "phil")) - require.Contains(t, stderr.String(), "user phil added with role user") + require.Contains(t, stdout.String(), "user phil added with role user") // Change role - app, _, _, stderr = newTestApp() + app, _, stdout, _ = newTestApp() require.Nil(t, runUserCommand(app, conf, "change-role", "phil", "admin")) - require.Contains(t, stderr.String(), "changed role for user phil to admin") + require.Contains(t, stdout.String(), "changed role for user phil to admin") } func TestCLI_User_Delete(t *testing.T) { @@ -96,15 +96,15 @@ func TestCLI_User_Delete(t *testing.T) { defer test.StopServer(t, s, port) // Add user - app, stdin, _, stderr := newTestApp() + app, stdin, stdout, _ := newTestApp() stdin.WriteString("mypass\nmypass") require.Nil(t, runUserCommand(app, conf, "add", "phil")) - require.Contains(t, stderr.String(), "user phil added with role user") + require.Contains(t, stdout.String(), "user phil added with role user") // Delete user - app, _, _, stderr = newTestApp() + app, _, stdout, _ = newTestApp() require.Nil(t, runUserCommand(app, conf, "del", "phil")) - require.Contains(t, stderr.String(), "user phil removed") + require.Contains(t, stdout.String(), "user phil removed") // Delete user again (does not exist) app, _, _, _ = newTestApp() diff --git a/cmd/webpush.go b/cmd/webpush.go index 249f91c8..fdcf4ff1 100644 --- a/cmd/webpush.go +++ b/cmd/webpush.go @@ -53,9 +53,9 @@ web-push-private-key: %s if err != nil { return err } - _, err = fmt.Fprintf(c.App.ErrWriter, "Web Push keys written to %s.\n", outputFile) + _, err = fmt.Fprintf(c.App.Writer, "Web Push keys written to %s.\n", outputFile) } else { - _, err = fmt.Fprintf(c.App.ErrWriter, `Web Push keys generated. Add the following lines to your config file: + _, err = fmt.Fprintf(c.App.Writer, `Web Push keys generated. Add the following lines to your config file: web-push-public-key: %s web-push-private-key: %s diff --git a/cmd/webpush_test.go b/cmd/webpush_test.go index 01e1a7a1..5a447831 100644 --- a/cmd/webpush_test.go +++ b/cmd/webpush_test.go @@ -1,6 +1,7 @@ package cmd import ( + "path/filepath" "testing" "github.com/stretchr/testify/require" @@ -9,16 +10,18 @@ import ( ) func TestCLI_WebPush_GenerateKeys(t *testing.T) { - app, _, _, stderr := newTestApp() + app, _, stdout, _ := newTestApp() require.Nil(t, runWebPushCommand(app, server.NewConfig(), "keys")) - require.Contains(t, stderr.String(), "Web Push keys generated.") + require.Contains(t, stdout.String(), "Web Push keys generated.") } func TestCLI_WebPush_WriteKeysToFile(t *testing.T) { - app, _, _, stderr := newTestApp() + tempDir := t.TempDir() + t.Chdir(tempDir) + app, _, stdout, _ := newTestApp() require.Nil(t, runWebPushCommand(app, server.NewConfig(), "keys", "--output-file=key-file.yaml")) - require.Contains(t, stderr.String(), "Web Push keys written to key-file.yaml") - require.FileExists(t, "key-file.yaml") + require.Contains(t, stdout.String(), "Web Push keys written to key-file.yaml") + require.FileExists(t, filepath.Join(tempDir, "key-file.yaml")) } func runWebPushCommand(app *cli.App, conf *server.Config, args ...string) error { diff --git a/docs/config.md b/docs/config.md index be15c9fc..10640c46 100644 --- a/docs/config.md +++ b/docs/config.md @@ -88,6 +88,7 @@ using Docker Compose (i.e. `docker-compose.yml`): NTFY_CACHE_FILE: /var/lib/ntfy/cache.db NTFY_AUTH_FILE: /var/lib/ntfy/auth.db NTFY_AUTH_DEFAULT_ACCESS: deny-all + NTFY_AUTH_USERS: 'phil:$2a$10$YLiO8U21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C:admin' NTFY_BEHIND_PROXY: true NTFY_ATTACHMENT_CACHE_DIR: /var/lib/ntfy/attachments NTFY_ENABLE_LOGIN: true @@ -188,19 +189,31 @@ ntfy's auth is implemented with a simple [SQLite](https://www.sqlite.org/)-based (`user` and `admin`) and per-topic `read` and `write` permissions using an [access control list (ACL)](https://en.wikipedia.org/wiki/Access-control_list). Access control entries can be applied to users as well as the special everyone user (`*`), which represents anonymous API access. -To set up auth, simply **configure the following two options**: +To set up auth, **configure the following options**: * `auth-file` is the user/access database; it is created automatically if it doesn't already exist; suggested location `/var/lib/ntfy/user.db` (easiest if deb/rpm package is used) * `auth-default-access` defines the default/fallback access if no access control entry is found; it can be - set to `read-write` (default), `read-only`, `write-only` or `deny-all`. + set to `read-write` (default), `read-only`, `write-only` or `deny-all`. **If you are setting up a private instance, + you'll want to set this to `deny-all`** (see [private instance example](#example-private-instance)). -Once configured, you can use the `ntfy user` command to [add or modify users](#users-and-roles), and the `ntfy access` command -lets you [modify the access control list](#access-control-list-acl) for specific users and topic patterns. Both of these -commands **directly edit the auth database** (as defined in `auth-file`), so they only work on the server, and only if the user -accessing them has the right permissions. +Once configured, you can use + +- the `ntfy user` command and the `auth-users` config option to [add or modify users](#users-and-roles) +- the `ntfy access` command and the `auth-access` option to [modify the access control list](#access-control-list-acl) +and topic patterns, and +- the `ntfy token` command and the `auth-tokens` config option to [manage access tokens](#access-tokens) for users. + +These commands **directly edit the auth database** (as defined in `auth-file`), so they only work on the server, +and only if the user accessing them has the right permissions. ### Users and roles +Users can be added to the ntfy user database in two different ways + +* [Using the CLI](#users-via-the-cli): Using the `ntfy user` command, you can manually add/update/remove users. +* [In the config](#users-via-the-config): You can provision users in the `server.yml` file via `auth-users` key. + +#### Users via the CLI The `ntfy user` command allows you to add/remove/change users in the ntfy user database, as well as change passwords or roles (`user` or `admin`). In practice, you'll often just create one admin user with `ntfy user add --role=admin ...` and be done with all this (see [example below](#example-private-instance)). @@ -221,12 +234,54 @@ ntfy user del phil # Delete user phil ntfy user change-pass phil # Change password for user phil ntfy user change-role phil admin # Make user phil an admin ntfy user change-tier phil pro # Change phil's tier to "pro" +ntfy user hash # Generate password hash, use with auth-users config option ``` +#### Users via the config +As an alternative to manually creating users via the `ntfy user` CLI command, you can provision users declaratively in +the `server.yml` file by adding them to the `auth-users` array. This is useful for general admins, or if you'd like to +deploy your ntfy server via Docker/Ansible without manually editing the database. + +The `auth-users` option is a list of users that are automatically created/updated when the server starts. Users +previously defined in the config but later removed will be deleted. Each entry is defined in the format `::`. + +Here's an example with two users: `phil` is an admin, `ben` is a regular user. + +=== "Declarative users in /etc/ntfy/server.yml" + ``` yaml + auth-file: "/var/lib/ntfy/user.db" + auth-users: + - "phil:$2a$10$YLiO8U21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C:admin" + - "ben:$2a$10$NKbrNb7HPMjtQXWJ0f1pouw03LDLT/WzlO9VAv44x84bRCkh19h6m:user" + ``` + +=== "Declarative users via env variables" + ``` + # Comma-separated list, use single quotes to avoid issues with the bcrypt hash + NTFY_AUTH_FILE='/var/lib/ntfy/user.db' + NTFY_AUTH_USERS='phil:$2a$10$YLiO8U21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C:admin,ben:$2a$10$NKbrNb7HPMjtQXWJ0f1pouw03LDLT/WzlO9VAv44x84bRCkh19h6m:user' + ``` + +The password hash can be created using `ntfy user hash` or an [online bcrypt generator](https://bcrypt-generator.com/) (though +note that you're putting your password in an untrusted website). + +!!! important + Users added declaratively via the config file are marked in the database as "provisioned users". Removing users + from the config file will **delete them from the database** the next time ntfy is restarted. + + Also, users that were originally manually created will be "upgraded" to be provisioned users if they are added to + the config. Adding a user manually, then adding it to the config, and then removing it from the config will hence + lead to the **deletion of that user**. + ### Access control list (ACL) The access control list (ACL) **manages access to topics for non-admin users, and for anonymous access (`everyone`/`*`)**. -Each entry represents the access permissions for a user to a specific topic or topic pattern. +Each entry represents the access permissions for a user to a specific topic or topic pattern. Entries can be created in +two different ways: +* [Using the CLI](#acl-entries-via-the-cli): Using the `ntfy access` command, you can manually edit the access control list. +* [In the config](#acl-entries-via-the-config): You can provision ACL entries in the `server.yml` file via `auth-access` key. + +#### ACL entries via the CLI The ACL can be displayed or modified with the `ntfy access` command: ``` @@ -282,6 +337,51 @@ User `ben` has three topic-specific entries. He can read, but not write to topic to topic `garagedoor` and all topics starting with the word `alerts` (wildcards). Clients that are not authenticated (called `*`/`everyone`) only have read access to the `announcements` and `server-stats` topics. +#### ACL entries via the config +As an alternative to manually creating ACL entries via the `ntfy access` CLI command, you can provision access control +entries declaratively in the `server.yml` file by adding them to the `auth-access` array, similar to the `auth-users` +option (see [users via the config](#users-via-the-config). + +The `auth-access` option is a list of access control entries that are automatically created/updated when the server starts. +When entries are removed, they are deleted from the database. Each entry is defined in the format `::`. + +The `` can be any existing, provisioned user as defined in the `auth-users` section (see [users via the config](#users-via-the-config)), +or `everyone`/`*` for anonymous access. The `` can be a specific topic name or a pattern with wildcards (`*`). The +`` can be one of the following: + +* `read-write` or `rw`: Allows both publishing to and subscribing to the topic +* `read-only`, `read`, or `ro`: Allows only subscribing to the topic +* `write-only`, `write`, or `wo`: Allows only publishing to the topic +* `deny-all`, `deny`, or `none`: Denies all access to the topic + +Here's an example with several ACL entries: + +=== "Declarative ACL entries in /etc/ntfy/server.yml" + ``` yaml + auth-file: "/var/lib/ntfy/user.db" + auth-users: + - "phil:$2a$10$YLiO8U21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C:user" + - "ben:$2a$10$NKbrNb7HPMjtQXWJ0f1pouw03LDLT/WzlO9VAv44x84bRCkh19h6m:user" + auth-access: + - "phil:mytopic:rw" + - "ben:alerts-*:rw" + - "ben:system-logs:ro" + - "*:announcements:ro" # or: "everyone:announcements,ro" + ``` + +=== "Declarative ACL entries via env variables" + ``` + # Comma-separated list + NTFY_AUTH_FILE='/var/lib/ntfy/user.db' + NTFY_AUTH_USERS='phil:$2a$10$YLiO8U21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C:user,ben:$2a$10$NKbrNb7HPMjtQXWJ0f1pouw03LDLT/WzlO9VAv44x84bRCkh19h6m:user' + NTFY_AUTH_ACCESS='phil:mytopic:rw,ben:alerts-*:rw,ben:system-logs:ro,*:announcements:ro' + ``` + +In this example, the `auth-users` section defines two users, `phil` and `ben`. The `auth-access` section defines +access control entries for these users. `phil` has read-write access to the topic `mytopic`, while `ben` has read-write +access to all topics starting with `alerts-` and read-only access to the topic `system-logs`. The last entry allows +anonymous users (i.e. clients that do not authenticate) to read the `announcements` topic. + ### Access tokens In addition to username/password auth, ntfy also provides authentication via access tokens. Access tokens are useful to avoid having to configure your password across multiple publishing/subscribing applications. For instance, you may @@ -292,6 +392,12 @@ want to use a dedicated token to publish from your backup host, and one from you and deleting the account, every action can be performed with a token. Granular access tokens are on the roadmap, but not yet implemented. +You can create access tokens in two different ways: + +* [Using the CLI](#tokens-via-the-cli): Using the `ntfy token` command, you can manually add/update/remove tokens. +* [In the config](#tokens-via-the-config): You can provision access tokens in the `server.yml` file via `auth-tokens` key. + +#### Tokens via the CLI The `ntfy token` command can be used to manage access tokens for users. Tokens can have labels, and they can expire automatically (or never expire). Each user can have up to 60 tokens (hardcoded). @@ -302,6 +408,7 @@ ntfy token list phil # Shows list of tokens for user phil ntfy token add phil # Create token for user phil which never expires ntfy token add --expires=2d phil # Create token for user phil which expires in 2 days ntfy token remove phil tk_th2sxr... # Delete token +ntfy token generate # Generate random token, can be used in auth-tokens config option ``` **Creating an access token:** @@ -309,32 +416,89 @@ ntfy token remove phil tk_th2sxr... # Delete token $ ntfy token add --expires=30d --label="backups" phil $ ntfy token list user phil -- tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2 (backups), expires 15 Mar 23 14:33 EDT, accessed from 0.0.0.0 at 13 Feb 23 13:33 EST +- tk_7eevizlsiwf9yi4uxsrs83r4352o0 (backups), expires 15 Mar 23 14:33 EDT, accessed from 0.0.0.0 at 13 Feb 23 13:33 EST ``` Once an access token is created, you can **use it to authenticate against the ntfy server, e.g. when you publish or subscribe to topics**. To learn how, check out [authenticate via access tokens](publish.md#access-tokens). -### Example: Private instance -The easiest way to configure a private instance is to set `auth-default-access` to `deny-all` in the `server.yml`: +#### Tokens via the config +Access tokens can be pre-provisioned in the `server.yml` configuration file using the `auth-tokens` config option. +This is useful for automated setups, Docker environments, or when you want to define tokens declaratively. -=== "/etc/ntfy/server.yml" +The `auth-tokens` option is a list of access tokens that are automatically created/updated when the server starts. +When entries are removed, they are deleted from the database. Each entry is defined in the format `:[: