From efef5876717f82e9a2b449ff590dd65a7ae8c02c Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Mon, 7 Jul 2025 22:36:01 +0200 Subject: [PATCH 01/22] WIP: Predefined users --- cmd/serve.go | 3 +++ cmd/user.go | 1 - server/config.go | 1 + server/server.go | 9 +++++++- user/manager.go | 55 +++++++++++++++++++++++++++++++----------------- 5 files changed, 48 insertions(+), 21 deletions(-) diff --git a/cmd/serve.go b/cmd/serve.go index ef4d98d5..516356c5 100644 --- a/cmd/serve.go +++ b/cmd/serve.go @@ -52,6 +52,7 @@ 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.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)"}), @@ -157,6 +158,7 @@ func execServe(c *cli.Context) error { authFile := c.String("auth-file") authStartupQueries := c.String("auth-startup-queries") authDefaultAccess := c.String("auth-default-access") + authUsers := c.StringSlice("auth-users") attachmentCacheDir := c.String("attachment-cache-dir") attachmentTotalSizeLimitStr := c.String("attachment-total-size-limit") attachmentFileSizeLimitStr := c.String("attachment-file-size-limit") @@ -406,6 +408,7 @@ func execServe(c *cli.Context) error { conf.AuthFile = authFile conf.AuthStartupQueries = authStartupQueries conf.AuthDefault = authDefault + conf.AuthUsers = nil // FIXME conf.AttachmentCacheDir = attachmentCacheDir conf.AttachmentTotalSizeLimit = attachmentTotalSizeLimit conf.AttachmentFileSizeLimit = attachmentFileSizeLimit diff --git a/cmd/user.go b/cmd/user.go index e6867b11..9902dace 100644 --- a/cmd/user.go +++ b/cmd/user.go @@ -94,7 +94,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. - `, }, { diff --git a/server/config.go b/server/config.go index 59b11c16..67554021 100644 --- a/server/config.go +++ b/server/config.go @@ -93,6 +93,7 @@ type Config struct { AuthFile string AuthStartupQueries string AuthDefault user.Permission + AuthUsers []user.User AuthBcryptCost int AuthStatsQueueWriterInterval time.Duration AttachmentCacheDir string diff --git a/server/server.go b/server/server.go index bfa7eb6b..10ad7d8e 100644 --- a/server/server.go +++ b/server/server.go @@ -189,7 +189,14 @@ 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, + BcryptCost: conf.AuthBcryptCost, + QueueWriterInterval: conf.AuthStatsQueueWriterInterval, + } + userManager, err = user.NewManager(authConfig) if err != nil { return nil, err } diff --git a/user/manager.go b/user/manager.go index 814ee827..04c3c878 100644 --- a/user/manager.go +++ b/user/manager.go @@ -441,36 +441,53 @@ var ( // 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 +} + +type Config struct { + Filename string + StartupQueries string + DefaultAccess Permission // Default permission if no ACL matches + ProvisionedUsers []*User // Predefined users to create on startup + ProvisionedAccess map[string][]*Grant // Predefined access grants to create on startup + BcryptCost int // Makes testing easier + QueueWriterInterval time.Duration } 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 + } + + // 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) + go manager.asyncQueueWriter(config.QueueWriterInterval) return manager, nil } @@ -843,7 +860,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 { @@ -873,7 +890,7 @@ func (a *Manager) AddUser(username, password string, role Role, hashed bool) err if hashed { hash = []byte(password) } else { - hash, err = bcrypt.GenerateFromPassword([]byte(password), a.bcryptCost) + hash, err = bcrypt.GenerateFromPassword([]byte(password), a.config.BcryptCost) if err != nil { return err } @@ -1205,7 +1222,7 @@ func (a *Manager) ChangePassword(username, password string, hashed bool) error { if hashed { hash = []byte(password) } else { - hash, err = bcrypt.GenerateFromPassword([]byte(password), a.bcryptCost) + hash, err = bcrypt.GenerateFromPassword([]byte(password), a.config.BcryptCost) if err != nil { return err } @@ -1387,7 +1404,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 From c0b5151baeee36cfeeb0fea1c3c83519d8acb490 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Thu, 10 Jul 2025 20:50:29 +0200 Subject: [PATCH 02/22] Predefined users --- .goreleaser.yml | 84 +++++++++++++++++++------------------------- Makefile | 2 +- cmd/serve.go | 32 ++++++++++++++--- cmd/user.go | 19 +++++++--- server/config.go | 3 +- server/server.go | 2 ++ user/manager.go | 28 ++++++++++++--- user/manager_test.go | 54 +++++++++++++++++++++++++--- 8 files changed, 157 insertions(+), 67 deletions(-) diff --git a/.goreleaser.yml b/.goreleaser.yml index fa423a86..f0cf08f6 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -1,76 +1,70 @@ +version: 2 before: hooks: - go mod download - go mod tidy builds: - - - id: ntfy_linux_amd64 + - id: ntfy_linux_amd64 binary: ntfy env: - CGO_ENABLED=1 # required for go-sqlite3 - tags: [sqlite_omit_load_extension,osusergo,netgo] + tags: [ sqlite_omit_load_extension,osusergo,netgo ] ldflags: - "-linkmode=external -extldflags=-static -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}" - goos: [linux] - goarch: [amd64] - - - id: ntfy_linux_armv6 + goos: [ linux ] + goarch: [ amd64 ] + - id: ntfy_linux_armv6 binary: ntfy env: - CGO_ENABLED=1 # required for go-sqlite3 - CC=arm-linux-gnueabi-gcc # apt install gcc-arm-linux-gnueabi - tags: [sqlite_omit_load_extension,osusergo,netgo] + tags: [ sqlite_omit_load_extension,osusergo,netgo ] ldflags: - "-linkmode=external -extldflags=-static -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}" - goos: [linux] - goarch: [arm] - goarm: [6] - - - id: ntfy_linux_armv7 + goos: [ linux ] + goarch: [ arm ] + goarm: [ 6 ] + - id: ntfy_linux_armv7 binary: ntfy env: - CGO_ENABLED=1 # required for go-sqlite3 - CC=arm-linux-gnueabi-gcc # apt install gcc-arm-linux-gnueabi - tags: [sqlite_omit_load_extension,osusergo,netgo] + tags: [ sqlite_omit_load_extension,osusergo,netgo ] ldflags: - "-linkmode=external -extldflags=-static -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}" - goos: [linux] - goarch: [arm] - goarm: [7] - - - id: ntfy_linux_arm64 + goos: [ linux ] + goarch: [ arm ] + goarm: [ 7 ] + - id: ntfy_linux_arm64 binary: ntfy env: - CGO_ENABLED=1 # required for go-sqlite3 - CC=aarch64-linux-gnu-gcc # apt install gcc-aarch64-linux-gnu - tags: [sqlite_omit_load_extension,osusergo,netgo] + tags: [ sqlite_omit_load_extension,osusergo,netgo ] ldflags: - "-linkmode=external -extldflags=-static -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}" - goos: [linux] - goarch: [arm64] - - - id: ntfy_windows_amd64 + goos: [ linux ] + goarch: [ arm64 ] + - id: ntfy_windows_amd64 binary: ntfy env: - CGO_ENABLED=0 # explicitly disable, since we don't need go-sqlite3 - tags: [noserver] # don't include server files + tags: [ noserver ] # don't include server files ldflags: - "-X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}" - goos: [windows] - goarch: [amd64] - - - id: ntfy_darwin_all + goos: [ windows ] + goarch: [ amd64 ] + - id: ntfy_darwin_all binary: ntfy env: - CGO_ENABLED=0 # explicitly disable, since we don't need go-sqlite3 - tags: [noserver] # don't include server files + tags: [ noserver ] # don't include server files ldflags: - "-X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}" - goos: [darwin] - goarch: [amd64, arm64] # will be combined to "universal binary" (see below) + goos: [ darwin ] + goarch: [ amd64, arm64 ] # will be combined to "universal binary" (see below) nfpms: - - - package_name: ntfy + - package_name: ntfy homepage: https://heckel.io/ntfy maintainer: Philipp C. Heckel description: Simple pub-sub notification service @@ -106,9 +100,8 @@ nfpms: preremove: "scripts/prerm.sh" postremove: "scripts/postrm.sh" archives: - - - id: ntfy_linux - builds: + - id: ntfy_linux + ids: - ntfy_linux_amd64 - ntfy_linux_armv6 - ntfy_linux_armv7 @@ -122,19 +115,17 @@ archives: - client/client.yml - client/ntfy-client.service - client/user/ntfy-client.service - - - id: ntfy_windows - builds: + - id: ntfy_windows + ids: - ntfy_windows_amd64 - format: zip + formats: [ zip ] wrap_in_directory: true files: - LICENSE - README.md - client/client.yml - - - id: ntfy_darwin - builds: + - id: ntfy_darwin + ids: - ntfy_darwin_all wrap_in_directory: true files: @@ -142,14 +133,13 @@ archives: - README.md - client/client.yml universal_binaries: - - - id: ntfy_darwin_all + - id: ntfy_darwin_all replace: true name_template: ntfy checksum: name_template: 'checksums.txt' snapshot: - name_template: "{{ .Tag }}-next" + version_template: "{{ .Tag }}-next" changelog: sort: asc filters: diff --git a/Makefile b/Makefile index 4355423e..82ab53e2 100644 --- a/Makefile +++ b/Makefile @@ -220,7 +220,7 @@ cli-deps-static-sites: touch server/docs/index.html server/site/app.html cli-deps-all: - go install github.com/goreleaser/goreleaser@latest + go install github.com/goreleaser/goreleaser/v2@latest cli-deps-gcc-armv6-armv7: which arm-linux-gnueabi-gcc || { echo "ERROR: ARMv6/ARMv7 cross compiler not installed. On Ubuntu, run: apt install gcc-arm-linux-gnueabi"; exit 1; } diff --git a/cmd/serve.go b/cmd/serve.go index 516356c5..abd9ac06 100644 --- a/cmd/serve.go +++ b/cmd/serve.go @@ -52,7 +52,7 @@ 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-provisioned-users", Aliases: []string{"auth_provisioned_users"}, EnvVars: []string{"NTFY_AUTH_PROVISIONED_USERS"}, Usage: "pre-provisioned declarative users"}), 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)"}), @@ -158,7 +158,8 @@ func execServe(c *cli.Context) error { authFile := c.String("auth-file") authStartupQueries := c.String("auth-startup-queries") authDefaultAccess := c.String("auth-default-access") - authUsers := c.StringSlice("auth-users") + authProvisionedUsersRaw := c.StringSlice("auth-provisioned-users") + //authProvisionedAccessRaw := c.StringSlice("auth-provisioned-access") attachmentCacheDir := c.String("attachment-cache-dir") attachmentTotalSizeLimitStr := c.String("attachment-total-size-limit") attachmentFileSizeLimitStr := c.String("attachment-file-size-limit") @@ -348,11 +349,33 @@ 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'") } + authProvisionedUsers := make([]*user.User, 0) + for _, userLine := range authProvisionedUsersRaw { + parts := strings.Split(userLine, ":") + if len(parts) != 3 { + return fmt.Errorf("invalid provisioned user %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 fmt.Errorf("invalid provisioned user %s, username invalid", userLine) + } else if passwordHash == "" { + return fmt.Errorf("invalid provisioned user %s, password hash cannot be empty", userLine) + } else if !user.AllowedRole(role) { + return fmt.Errorf("invalid provisioned user %s, role %s is not allowed, allowed roles are 'admin' or 'user'", userLine, role) + } + authProvisionedUsers = append(authProvisionedUsers, &user.User{ + Name: username, + Hash: passwordHash, + Role: role, + }) + } // Special case: Unset default if listenHTTP == "-" { @@ -408,7 +431,8 @@ func execServe(c *cli.Context) error { conf.AuthFile = authFile conf.AuthStartupQueries = authStartupQueries conf.AuthDefault = authDefault - conf.AuthUsers = nil // FIXME + conf.AuthProvisionedUsers = authProvisionedUsers + conf.AuthProvisionedAccess = nil // FIXME conf.AttachmentCacheDir = attachmentCacheDir conf.AttachmentTotalSizeLimit = attachmentTotalSizeLimit conf.AttachmentFileSizeLimit = attachmentFileSizeLimit diff --git a/cmd/user.go b/cmd/user.go index 9902dace..7519438c 100644 --- a/cmd/user.go +++ b/cmd/user.go @@ -224,7 +224,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 { @@ -250,7 +250,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 == "" { @@ -278,7 +278,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 { @@ -302,7 +302,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 { @@ -344,7 +344,16 @@ 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, + ProvisionedUsers: nil, //FIXME + ProvisionedAccess: nil, //FIXME + BcryptCost: user.DefaultUserPasswordBcryptCost, + QueueWriterInterval: user.DefaultUserStatsQueueWriterInterval, + } + return user.NewManager(authConfig) } func readPasswordAndConfirm(c *cli.Context) (string, error) { diff --git a/server/config.go b/server/config.go index 67554021..c163614f 100644 --- a/server/config.go +++ b/server/config.go @@ -93,7 +93,8 @@ type Config struct { AuthFile string AuthStartupQueries string AuthDefault user.Permission - AuthUsers []user.User + AuthProvisionedUsers []*user.User + AuthProvisionedAccess map[string][]*user.Grant AuthBcryptCost int AuthStatsQueueWriterInterval time.Duration AttachmentCacheDir string diff --git a/server/server.go b/server/server.go index 10ad7d8e..cba9b181 100644 --- a/server/server.go +++ b/server/server.go @@ -193,6 +193,8 @@ func New(conf *Config) (*Server, error) { Filename: conf.AuthFile, StartupQueries: conf.AuthStartupQueries, DefaultAccess: conf.AuthDefault, + ProvisionedUsers: conf.AuthProvisionedUsers, + ProvisionedAccess: conf.AuthProvisionedAccess, BcryptCost: conf.AuthBcryptCost, QueueWriterInterval: conf.AuthStatsQueueWriterInterval, } diff --git a/user/manager.go b/user/manager.go index 04c3c878..8932f34a 100644 --- a/user/manager.go +++ b/user/manager.go @@ -449,13 +449,13 @@ type Manager struct { } type Config struct { - Filename string - StartupQueries string + 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 ProvisionedUsers []*User // Predefined users to create on startup ProvisionedAccess map[string][]*Grant // Predefined access grants to create on startup - BcryptCost int // Makes testing easier - QueueWriterInterval time.Duration + 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) @@ -469,7 +469,6 @@ func NewManager(config *Config) (*Manager, error) { if config.QueueWriterInterval.Seconds() <= 0 { config.QueueWriterInterval = DefaultUserStatsQueueWriterInterval } - // Open DB and run setup queries db, err := sql.Open("sqlite3", config.Filename) if err != nil { @@ -487,6 +486,9 @@ func NewManager(config *Config) (*Manager, error) { statsQueue: make(map[string]*Stats), tokenQueue: make(map[string]*TokenUpdate), } + if err := manager.provisionUsers(); err != nil { + return nil, err + } go manager.asyncQueueWriter(config.QueueWriterInterval) return manager, nil } @@ -1522,6 +1524,22 @@ func (a *Manager) Close() error { return a.db.Close() } +func (a *Manager) provisionUsers() error { + for _, user := range a.config.ProvisionedUsers { + if err := a.AddUser(user.Name, user.Hash, user.Role, true); err != nil && !errors.Is(err, ErrUserExists) { + return err + } + } + for username, grants := range a.config.ProvisionedAccess { + for _, grant := range grants { + if err := a.AllowAccess(username, grant.TopicPattern, grant.Allow); 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 { diff --git a/user/manager_test.go b/user/manager_test.go index 89f35e3c..b57c762c 100644 --- a/user/manager_test.go +++ b/user/manager_test.go @@ -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,24 @@ 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, + ProvisionedUsers: []*User{ + {Name: "phil", Hash: "$2a$10$YLiO8U21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C", Role: RoleAdmin}, + }, + } + a, err := NewManager(conf) + require.Nil(t, err) + users, err := a.Users() + require.Nil(t, err) + for _, u := range users { + fmt.Println(u.ID, u.Name, u.Role) + } +} + func TestToFromSQLWildcard(t *testing.T) { require.Equal(t, "up%", toSQLWildcard("up*")) require.Equal(t, "up\\_%", toSQLWildcard("up_*")) @@ -1336,7 +1375,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 } From f59df0f40ada3221a1857c665c9c82e0fdf63d2e Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Mon, 21 Jul 2025 17:44:00 +0200 Subject: [PATCH 03/22] Works --- Makefile | 2 +- cmd/access.go | 30 ++++-- cmd/serve.go | 109 ++++++++++++++----- cmd/user.go | 3 +- go.sum | 31 ------ server/message_cache.go | 7 ++ server/server.go | 5 +- server/server.yml | 6 ++ server/server_admin.go | 2 +- user/manager.go | 230 +++++++++++++++++++++++++++++++--------- user/manager_test.go | 10 +- user/types.go | 26 ++--- util/util.go | 12 +++ 13 files changed, 333 insertions(+), 140 deletions(-) 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..10247b5f 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) } @@ -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 { diff --git a/cmd/serve.go b/cmd/serve.go index 50314b88..ef37ee6f 100644 --- a/cmd/serve.go +++ b/cmd/serve.go @@ -48,7 +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-provisioned-users", Aliases: []string{"auth_provisioned_users"}, EnvVars: []string{"NTFY_AUTH_PROVISIONED_USERS"}, Usage: "pre-provisioned declarative users"}), + 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)"}), @@ -155,8 +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") - authProvisionedUsersRaw := c.StringSlice("auth-provisioned-users") - //authProvisionedAccessRaw := c.StringSlice("auth-provisioned-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") @@ -352,27 +353,13 @@ func execServe(c *cli.Context) error { if err != nil { return errors.New("if set, auth-default-access must start set to 'read-write', 'read-only', 'write-only' or 'deny-all'") } - authProvisionedUsers := make([]*user.User, 0) - for _, userLine := range authProvisionedUsersRaw { - parts := strings.Split(userLine, ":") - if len(parts) != 3 { - return fmt.Errorf("invalid provisioned user %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 fmt.Errorf("invalid provisioned user %s, username invalid", userLine) - } else if passwordHash == "" { - return fmt.Errorf("invalid provisioned user %s, password hash cannot be empty", userLine) - } else if !user.AllowedRole(role) { - return fmt.Errorf("invalid provisioned user %s, role %s is not allowed, allowed roles are 'admin' or 'user'", userLine, role) - } - authProvisionedUsers = append(authProvisionedUsers, &user.User{ - Name: username, - Hash: passwordHash, - Role: role, - }) + authProvisionUsers, err := parseProvisionUsers(authProvisionUsersRaw) + if err != nil { + return err + } + authProvisionAccess, err := parseProvisionAccess(authProvisionUsers, authProvisionAccessRaw) + if err != nil { + return err } // Special case: Unset default @@ -429,8 +416,8 @@ func execServe(c *cli.Context) error { conf.AuthFile = authFile conf.AuthStartupQueries = authStartupQueries conf.AuthDefault = authDefault - conf.AuthProvisionedUsers = authProvisionedUsers - conf.AuthProvisionedAccess = nil // FIXME + conf.AuthProvisionedUsers = authProvisionUsers + conf.AuthProvisionedAccess = authProvisionAccess conf.AttachmentCacheDir = attachmentCacheDir conf.AttachmentTotalSizeLimit = attachmentTotalSizeLimit conf.AttachmentFileSizeLimit = attachmentFileSizeLimit @@ -544,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 passwordHash == "" { + return nil, fmt.Errorf("invalid auth-provision-users: %s, password hash cannot be empty", userLine) + } 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 { diff --git a/cmd/user.go b/cmd/user.go index 31f4c31b..0a6e24a1 100644 --- a/cmd/user.go +++ b/cmd/user.go @@ -349,8 +349,7 @@ func createUserManager(c *cli.Context) (*user.Manager, error) { Filename: authFile, StartupQueries: authStartupQueries, DefaultAccess: authDefault, - ProvisionedUsers: nil, //FIXME - ProvisionedAccess: nil, //FIXME + ProvisionEnabled: false, // Do not re-provision users on manager initialization BcryptCost: user.DefaultUserPasswordBcryptCost, QueueWriterInterval: user.DefaultUserStatsQueueWriterInterval, } diff --git a/go.sum b/go.sum index 1f98da35..575b5c22 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/server/message_cache.go b/server/message_cache.go index e314ace3..03cb4969 100644 --- a/server/message_cache.go +++ b/server/message_cache.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "net/netip" + "path/filepath" "strings" "time" @@ -286,6 +287,12 @@ 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) { + // 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", filename) if err != nil { return nil, err diff --git a/server/server.go b/server/server.go index d585faa0..d3ef9cbb 100644 --- a/server/server.go +++ b/server/server.go @@ -200,8 +200,9 @@ func New(conf *Config) (*Server, error) { Filename: conf.AuthFile, StartupQueries: conf.AuthStartupQueries, DefaultAccess: conf.AuthDefault, - ProvisionedUsers: conf.AuthProvisionedUsers, - ProvisionedAccess: conf.AuthProvisionedAccess, + ProvisionEnabled: true, // Enable provisioning of users and access + ProvisionUsers: conf.AuthProvisionedUsers, + ProvisionAccess: conf.AuthProvisionedAccess, BcryptCost: conf.AuthBcryptCost, QueueWriterInterval: conf.AuthStatsQueueWriterInterval, } diff --git a/server/server.yml b/server/server.yml index db968498..02af7383 100644 --- a/server/server.yml +++ b/server/server.yml @@ -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 "::", 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 "::", 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: # 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. diff --git a/server/server_admin.go b/server/server_admin.go index eb362956..b724d4b7 100644 --- a/server/server_admin.go +++ b/server/server_admin.go @@ -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{ diff --git a/user/manager.go b/user/manager.go index 8932f34a..f2f4875d 100644 --- a/user/manager.go +++ b/user/manager.go @@ -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 = ? @@ -427,6 +431,15 @@ const ( migrate4To5UpdateQueries = ` UPDATE user_access SET topic = REPLACE(topic, '_', '\_'); ` + + // 5 -> 6 + migrate5To6UpdateQueries = ` + ALTER TABLE user ADD COLUMN provisioned INT NOT NULL DEFAULT (0); + ALTER TABLE user ALTER COLUMN provisioned DROP DEFAULT; + + ALTER TABLE user_access ADD COLUMN provisioned INT NOT NULL DEFAULT (0); + ALTER TABLE user_access ALTER COLUMN provisioned DROP DEFAULT; + ` ) var ( @@ -435,6 +448,7 @@ var ( 2: migrateFrom2, 3: migrateFrom3, 4: migrateFrom4, + 5: migrateFrom5, } ) @@ -452,8 +466,9 @@ 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 - ProvisionedUsers []*User // Predefined users to create on startup - ProvisionedAccess map[string][]*Grant // Predefined access grants to create on startup + ProvisionEnabled bool // Enable auto-provisioning of users and access grants + 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 } @@ -469,6 +484,11 @@ func NewManager(config *Config) (*Manager, error) { 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 { @@ -486,7 +506,7 @@ func NewManager(config *Config) (*Manager, error) { statsQueue: make(map[string]*Stats), tokenQueue: make(map[string]*TokenUpdate), } - if err := manager.provisionUsers(); err != nil { + if err := manager.maybeProvisionUsersAndAccess(); err != nil { return nil, err } go manager.asyncQueueWriter(config.QueueWriterInterval) @@ -586,7 +606,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 @@ -884,6 +904,13 @@ 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 } @@ -899,8 +926,8 @@ func (a *Manager) AddUser(username, password string, role Role, hashed bool) 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 @@ -911,11 +938,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 @@ -1029,24 +1062,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, @@ -1097,8 +1132,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 @@ -1108,7 +1143,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 @@ -1124,15 +1160,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 @@ -1218,9 +1255,14 @@ func (a *Manager) ReservationOwner(topic string) (string, error) { // ChangePassword changes a user's password func (a *Manager) ChangePassword(username, password string, hashed bool) 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 []byte var err error - if hashed { hash = []byte(password) } else { @@ -1229,7 +1271,7 @@ func (a *Manager) ChangePassword(username, password string, hashed bool) error { 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 @@ -1238,14 +1280,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 } } @@ -1325,13 +1373,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 @@ -1524,20 +1578,65 @@ func (a *Manager) Close() error { return a.db.Close() } -func (a *Manager) provisionUsers() error { - for _, user := range a.config.ProvisionedUsers { - if err := a.AddUser(user.Name, user.Hash, user.Role, true); err != nil && !errors.Is(err, ErrUserExists) { - return err - } +func (a *Manager) maybeProvisionUsersAndAccess() error { + if !a.config.ProvisionEnabled { + return nil } - for username, grants := range a.config.ProvisionedAccess { - for _, grant := range grants { - if err := a.AllowAccess(username, grant.TopicPattern, grant.Allow); err != nil { - return err + 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) + } } } - } - return nil + // 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.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, @@ -1711,6 +1810,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{} @@ -1724,3 +1839,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() +} diff --git a/user/manager_test.go b/user/manager_test.go index b57c762c..42def63f 100644 --- a/user/manager_test.go +++ b/user/manager_test.go @@ -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) @@ -1201,16 +1201,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) { diff --git a/user/types.go b/user/types.go index 6f6b1f69..90eeefce 100644 --- a/user/types.go +++ b/user/types.go @@ -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 diff --git a/util/util.go b/util/util.go index 73b227af..3648e3a4 100644 --- a/util/util.go +++ b/util/util.go @@ -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) From 4457e9e26ff82d290ff82e8e8446ee5066be09db Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sat, 26 Jul 2025 11:16:33 +0200 Subject: [PATCH 04/22] Migration --- user/manager.go | 81 ++++++++++++++++++++++++++--- user/manager_test.go | 119 +++++++++++++++++++++++++++++++++++-------- 2 files changed, 173 insertions(+), 27 deletions(-) diff --git a/user/manager.go b/user/manager.go index f2f4875d..09db145e 100644 --- a/user/manager.go +++ b/user/manager.go @@ -316,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` @@ -434,11 +434,78 @@ const ( // 5 -> 6 migrate5To6UpdateQueries = ` - ALTER TABLE user ADD COLUMN provisioned INT NOT NULL DEFAULT (0); - ALTER TABLE user ALTER COLUMN provisioned DROP DEFAULT; + PRAGMA foreign_keys=off; - ALTER TABLE user_access ADD COLUMN provisioned INT NOT NULL DEFAULT (0); - ALTER TABLE user_access ALTER COLUMN provisioned DROP DEFAULT; + -- 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; ` ) @@ -1422,10 +1489,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() diff --git a/user/manager_test.go b/user/manager_test.go index 42def63f..c2887ff3 100644 --- a/user/manager_test.go +++ b/user/manager_test.go @@ -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 @@ -1099,19 +1099,98 @@ func TestManager_Topic_Wildcard_With_Underscore(t *testing.T) { func TestManager_WithProvisionedUsers(t *testing.T) { f := filepath.Join(t.TempDir(), "user.db") conf := &Config{ - Filename: f, - DefaultAccess: PermissionReadWrite, - ProvisionedUsers: []*User{ - {Name: "phil", Hash: "$2a$10$YLiO8U21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C", Role: RoleAdmin}, + 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) - for _, u := range users { - fmt.Println(u.ID, u.Name, u.Role) + 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) { From f99801a2e6d8c7675fd624ff8e16075dce734f4f Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sat, 26 Jul 2025 12:14:21 +0200 Subject: [PATCH 05/22] Add "ntfy user hash" --- cmd/serve.go | 4 ++-- cmd/user.go | 33 +++++++++++++++++++++++++++++++++ user/manager.go | 29 ++++++++++++++++++++++------- user/manager_test.go | 4 ++-- user/types.go | 9 +++++++++ 5 files changed, 68 insertions(+), 11 deletions(-) diff --git a/cmd/serve.go b/cmd/serve.go index ef37ee6f..882debdc 100644 --- a/cmd/serve.go +++ b/cmd/serve.go @@ -543,8 +543,8 @@ func parseProvisionUsers(usersRaw []string) ([]*user.User, error) { 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 passwordHash == "" { - return nil, fmt.Errorf("invalid auth-provision-users: %s, password hash cannot be empty", 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) } diff --git a/cmd/user.go b/cmd/user.go index 0a6e24a1..49504a94 100644 --- a/cmd/user.go +++ b/cmd/user.go @@ -133,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 `, }, { @@ -289,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) diff --git a/user/manager.go b/user/manager.go index 09db145e..ecef8747 100644 --- a/user/manager.go +++ b/user/manager.go @@ -981,12 +981,15 @@ func (a *Manager) addUserTx(tx *sql.Tx, username, password string, role Role, ha 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.config.BcryptCost) + hash, err = a.HashPassword(password) if err != nil { return err } @@ -1328,12 +1331,15 @@ func (a *Manager) ChangePassword(username, password string, hashed bool) error { } func (a *Manager) changePasswordTx(tx *sql.Tx, username, password string, hashed bool) error { - var hash []byte + 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.config.BcryptCost) + hash, err = a.HashPassword(password) if err != nil { return err } @@ -1640,6 +1646,15 @@ 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() @@ -1681,7 +1696,7 @@ func (a *Manager) maybeProvisionUsersAndAccess() error { 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.Hash != user.Hash || existingUser.Role != user.Role { + } 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) diff --git a/user/manager_test.go b/user/manager_test.go index c2887ff3..94bd1b97 100644 --- a/user/manager_test.go +++ b/user/manager_test.go @@ -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") diff --git a/user/types.go b/user/types.go index 90eeefce..aaf77d1f 100644 --- a/user/types.go +++ b/user/types.go @@ -274,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") @@ -281,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") From 141ddb3a5187a7e3281f99e96d7e11bf09871388 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sat, 26 Jul 2025 12:20:11 +0200 Subject: [PATCH 06/22] Comments --- user/manager.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/user/manager.go b/user/manager.go index ecef8747..70d16370 100644 --- a/user/manager.go +++ b/user/manager.go @@ -529,11 +529,12 @@ type Manager struct { 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 + 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 From f3c67f1d716f6ee50af89cfd51a908d962bdc73a Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sun, 27 Jul 2025 11:02:34 +0200 Subject: [PATCH 07/22] Refuse to update manually created users --- cmd/serve.go | 4 ++-- server/config.go | 4 ++-- server/server.go | 4 ++-- user/manager.go | 18 +++++++++++------- user/manager_test.go | 33 +++++++++++++++++++++++++++++++++ 5 files changed, 50 insertions(+), 13 deletions(-) diff --git a/cmd/serve.go b/cmd/serve.go index 882debdc..7e7e56e1 100644 --- a/cmd/serve.go +++ b/cmd/serve.go @@ -416,8 +416,8 @@ func execServe(c *cli.Context) error { conf.AuthFile = authFile conf.AuthStartupQueries = authStartupQueries conf.AuthDefault = authDefault - conf.AuthProvisionedUsers = authProvisionUsers - conf.AuthProvisionedAccess = authProvisionAccess + conf.AuthProvisionUsers = authProvisionUsers + conf.AuthProvisionAccess = authProvisionAccess conf.AttachmentCacheDir = attachmentCacheDir conf.AttachmentTotalSizeLimit = attachmentTotalSizeLimit conf.AttachmentFileSizeLimit = attachmentFileSizeLimit diff --git a/server/config.go b/server/config.go index 86971e47..5cf0b035 100644 --- a/server/config.go +++ b/server/config.go @@ -95,8 +95,8 @@ type Config struct { AuthFile string AuthStartupQueries string AuthDefault user.Permission - AuthProvisionedUsers []*user.User - AuthProvisionedAccess map[string][]*user.Grant + AuthProvisionUsers []*user.User + AuthProvisionAccess map[string][]*user.Grant AuthBcryptCost int AuthStatsQueueWriterInterval time.Duration AttachmentCacheDir string diff --git a/server/server.go b/server/server.go index 4fcd9ba3..dbe61905 100644 --- a/server/server.go +++ b/server/server.go @@ -201,8 +201,8 @@ func New(conf *Config) (*Server, error) { StartupQueries: conf.AuthStartupQueries, DefaultAccess: conf.AuthDefault, ProvisionEnabled: true, // Enable provisioning of users and access - ProvisionUsers: conf.AuthProvisionedUsers, - ProvisionAccess: conf.AuthProvisionedAccess, + ProvisionUsers: conf.AuthProvisionUsers, + ProvisionAccess: conf.AuthProvisionAccess, BcryptCost: conf.AuthBcryptCost, QueueWriterInterval: conf.AuthStatsQueueWriterInterval, } diff --git a/user/manager.go b/user/manager.go index 70d16370..2e176450 100644 --- a/user/manager.go +++ b/user/manager.go @@ -1697,13 +1697,17 @@ func (a *Manager) maybeProvisionUsersAndAccess() error { 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) + } else { + if !existingUser.Provisioned { + log.Tag(tag).Warn("Refusing to update manually user %s", user.Name) + } else if 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) + } } } } diff --git a/user/manager_test.go b/user/manager_test.go index 94bd1b97..2ce078f3 100644 --- a/user/manager_test.go +++ b/user/manager_test.go @@ -1193,6 +1193,39 @@ func TestManager_WithProvisionedUsers(t *testing.T) { require.Equal(t, "*", users[1].Name) } +func TestManager_DoNotUpdateNonProvisionedUsers(t *testing.T) { + f := filepath.Join(t.TempDir(), "user.db") + conf := &Config{ + Filename: f, + DefaultAccess: PermissionReadWrite, + ProvisionEnabled: true, + ProvisionUsers: []*User{}, + ProvisionAccess: map[string][]*Grant{}, + } + a, err := NewManager(conf) + require.Nil(t, err) + + // Manually add user + require.Nil(t, a.AddUser("philuser", "manual", RoleUser, false)) + + // Re-open the DB (second app start) + require.Nil(t, a.db.Close()) + conf.ProvisionUsers = []*User{ + {Name: "philuser", Hash: "$2a$10$AAAU21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C", Role: RoleAdmin}, + } + 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, "philuser", users[0].Name) + require.Equal(t, RoleUser, users[0].Role) // Should not have been updated + require.NotEqual(t, "$2a$10$AAAU21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C", users[0].Hash) +} + func TestToFromSQLWildcard(t *testing.T) { require.Equal(t, "up%", toSQLWildcard("up*")) require.Equal(t, "up\\_%", toSQLWildcard("up_*")) From fe545423c518b42534652aebf4f127a062f09eb3 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sun, 27 Jul 2025 12:10:16 +0200 Subject: [PATCH 08/22] Change to auth-(users|access), upgrade manually added users to provision users --- cmd/serve.go | 40 ++++++++++++++++++++-------------------- server/config.go | 4 ++-- server/server.go | 4 ++-- user/manager.go | 38 ++++++++++++++++++++++++++++---------- user/manager_test.go | 20 ++++++++++---------- 5 files changed, 62 insertions(+), 44 deletions(-) diff --git a/cmd/serve.go b/cmd/serve.go index 7e7e56e1..dc503ccc 100644 --- a/cmd/serve.go +++ b/cmd/serve.go @@ -48,8 +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.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.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)"}), @@ -156,8 +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") + authUsersRaw := c.StringSlice("auth-users") + authAccessRaw := c.StringSlice("auth-access") attachmentCacheDir := c.String("attachment-cache-dir") attachmentTotalSizeLimitStr := c.String("attachment-total-size-limit") attachmentFileSizeLimitStr := c.String("attachment-file-size-limit") @@ -353,11 +353,11 @@ func execServe(c *cli.Context) error { 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) + authUsers, err := parseUsers(authUsersRaw) if err != nil { return err } - authProvisionAccess, err := parseProvisionAccess(authProvisionUsers, authProvisionAccessRaw) + authAccess, err := parseAccess(authUsers, authAccessRaw) if err != nil { return err } @@ -416,8 +416,8 @@ func execServe(c *cli.Context) error { conf.AuthFile = authFile conf.AuthStartupQueries = authStartupQueries conf.AuthDefault = authDefault - conf.AuthProvisionUsers = authProvisionUsers - conf.AuthProvisionAccess = authProvisionAccess + conf.AuthUsers = authUsers + conf.AuthAccess = authAccess conf.AttachmentCacheDir = attachmentCacheDir conf.AttachmentTotalSizeLimit = attachmentTotalSizeLimit conf.AttachmentFileSizeLimit = attachmentFileSizeLimit @@ -531,22 +531,22 @@ func parseIPHostPrefix(host string) (prefixes []netip.Prefix, err error) { return } -func parseProvisionUsers(usersRaw []string) ([]*user.User, error) { +func parseUsers(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) + 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-provision-users: %s, username invalid", userLine) + return nil, fmt.Errorf("invalid auth-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()) + return nil, fmt.Errorf("invalid auth-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) + return nil, fmt.Errorf("invalid auth-users: %s, role %s is not allowed, allowed roles are 'admin' or 'user'", userLine, role) } provisionUsers = append(provisionUsers, &user.User{ Name: username, @@ -558,12 +558,12 @@ func parseProvisionUsers(usersRaw []string) ([]*user.User, error) { return provisionUsers, nil } -func parseProvisionAccess(provisionUsers []*user.User, provisionAccessRaw []string) (map[string][]*user.Grant, error) { +func parseAccess(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) + return nil, fmt.Errorf("invalid auth-access: %s, expected format: 'user:topic:permission'", accessLine) } username := strings.TrimSpace(parts[0]) if username == userEveryone { @@ -574,20 +574,20 @@ func parseProvisionAccess(provisionUsers []*user.User, provisionAccessRaw []stri }) if username != user.Everyone { if !exists { - return nil, fmt.Errorf("invalid auth-provision-access: %s, user %s is not provisioned", accessLine, username) + 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-provision-access: %s, username %s invalid", accessLine, username) + return nil, fmt.Errorf("invalid auth-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) + 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-provision-access: %s, topic pattern %s invalid", accessLine, 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-provision-access: %s, permission %s invalid, %s", accessLine, parts[2], err.Error()) + 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) diff --git a/server/config.go b/server/config.go index 5cf0b035..99d829b2 100644 --- a/server/config.go +++ b/server/config.go @@ -95,8 +95,8 @@ type Config struct { AuthFile string AuthStartupQueries string AuthDefault user.Permission - AuthProvisionUsers []*user.User - AuthProvisionAccess map[string][]*user.Grant + AuthUsers []*user.User + AuthAccess map[string][]*user.Grant AuthBcryptCost int AuthStatsQueueWriterInterval time.Duration AttachmentCacheDir string diff --git a/server/server.go b/server/server.go index dbe61905..55fa3af7 100644 --- a/server/server.go +++ b/server/server.go @@ -201,8 +201,8 @@ func New(conf *Config) (*Server, error) { StartupQueries: conf.AuthStartupQueries, DefaultAccess: conf.AuthDefault, ProvisionEnabled: true, // Enable provisioning of users and access - ProvisionUsers: conf.AuthProvisionUsers, - ProvisionAccess: conf.AuthProvisionAccess, + Users: conf.AuthUsers, + Access: conf.AuthAccess, BcryptCost: conf.AuthBcryptCost, QueueWriterInterval: conf.AuthStatsQueueWriterInterval, } diff --git a/user/manager.go b/user/manager.go index 2e176450..5418f534 100644 --- a/user/manager.go +++ b/user/manager.go @@ -184,6 +184,7 @@ const ( selectUserCountQuery = `SELECT COUNT(*) FROM user` updateUserPassQuery = `UPDATE user SET pass = ? WHERE user = ?` updateUserRoleQuery = `UPDATE user SET role = ? WHERE user = ?` + updateUserProvisionedQuery = `UPDATE user SET provisioned = ? WHERE user = ?` updateUserPrefsQuery = `UPDATE user SET prefs = ? WHERE id = ?` updateUserStatsQuery = `UPDATE user SET stats_messages = ?, stats_emails = ?, stats_calls = ? WHERE id = ?` updateUserStatsResetAllQuery = `UPDATE user SET stats_messages = 0, stats_emails = 0, stats_calls = 0` @@ -535,8 +536,8 @@ type Config struct { 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 + Users []*User // Predefined users to create on startup + Access 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 } @@ -1374,6 +1375,21 @@ func (a *Manager) changeRoleTx(tx *sql.Tx, username string, role Role) error { return nil } +// ChangeProvisioned changes the provisioned status of a user. This is used to mark users as +// provisioned. A provisioned user is a user defined in the config file. +func (a *Manager) ChangeProvisioned(username string, provisioned bool) error { + return execTx(a.db, func(tx *sql.Tx) error { + return a.changeProvisionedTx(tx, username, provisioned) + }) +} + +func (a *Manager) changeProvisionedTx(tx *sql.Tx, username string, provisioned bool) error { + if _, err := tx.Exec(updateUserProvisionedQuery, provisioned, username); err != nil { + return err + } + return nil +} + // ChangeTier changes a user's tier using the tier code. This function does not delete reservations, messages, // or attachments, even if the new tier has lower limits in this regard. That has to be done elsewhere. func (a *Manager) ChangeTier(username, tier string) error { @@ -1669,7 +1685,7 @@ func (a *Manager) maybeProvisionUsersAndAccess() error { if err != nil { return err } - provisionUsernames := util.Map(a.config.ProvisionUsers, func(u *User) string { + provisionUsernames := util.Map(a.config.Users, func(u *User) string { return u.Name }) return execTx(a.db, func(tx *sql.Tx) error { @@ -1678,14 +1694,13 @@ func (a *Manager) maybeProvisionUsersAndAccess() error { 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 { + for _, user := range a.config.Users { if user.Name == Everyone { continue } @@ -1693,18 +1708,21 @@ func (a *Manager) maybeProvisionUsersAndAccess() error { 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 { - log.Tag(tag).Warn("Refusing to update manually user %s", user.Name) - } else if existingUser.Hash != user.Hash || existingUser.Role != user.Role { - log.Tag(tag).Info("Updating provisioned user %s", user.Name) + if err := a.changeProvisionedTx(tx, user.Name, true); err != nil { + return fmt.Errorf("failed to change provisioned status for user %s: %v", user.Name, err) + } + } + if existingUser.Hash != user.Hash { 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 existingUser.Role != user.Role { 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) } @@ -1715,7 +1733,7 @@ func (a *Manager) maybeProvisionUsersAndAccess() error { if _, err := tx.Exec(deleteUserAccessProvisionedQuery); err != nil { return err } - for username, grants := range a.config.ProvisionAccess { + for username, grants := range a.config.Access { for _, grant := range grants { if err := a.allowAccessTx(tx, username, grant.TopicPattern, grant.Permission, true); err != nil { return err diff --git a/user/manager_test.go b/user/manager_test.go index 2ce078f3..d55726a3 100644 --- a/user/manager_test.go +++ b/user/manager_test.go @@ -1102,11 +1102,11 @@ func TestManager_WithProvisionedUsers(t *testing.T) { Filename: f, DefaultAccess: PermissionReadWrite, ProvisionEnabled: true, - ProvisionUsers: []*User{ + Users: []*User{ {Name: "philuser", Hash: "$2a$10$YLiO8U21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C", Role: RoleUser}, {Name: "philadmin", Hash: "$2a$10$YLiO8U21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C", Role: RoleAdmin}, }, - ProvisionAccess: map[string][]*Grant{ + Access: map[string][]*Grant{ "philuser": { {TopicPattern: "stats", Permission: PermissionReadWrite}, {TopicPattern: "secret", Permission: PermissionRead}, @@ -1144,10 +1144,10 @@ func TestManager_WithProvisionedUsers(t *testing.T) { // Re-open the DB (second app start) require.Nil(t, a.db.Close()) - conf.ProvisionUsers = []*User{ + conf.Users = []*User{ {Name: "philuser", Hash: "$2a$10$AAAU21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C", Role: RoleUser}, } - conf.ProvisionAccess = map[string][]*Grant{ + conf.Access = map[string][]*Grant{ "philuser": { {TopicPattern: "stats12", Permission: PermissionReadWrite}, {TopicPattern: "secret12", Permission: PermissionRead}, @@ -1178,8 +1178,8 @@ func TestManager_WithProvisionedUsers(t *testing.T) { // Re-open the DB again (third app start) require.Nil(t, a.db.Close()) - conf.ProvisionUsers = []*User{} - conf.ProvisionAccess = map[string][]*Grant{} + conf.Users = []*User{} + conf.Access = map[string][]*Grant{} a, err = NewManager(conf) require.Nil(t, err) @@ -1199,8 +1199,8 @@ func TestManager_DoNotUpdateNonProvisionedUsers(t *testing.T) { Filename: f, DefaultAccess: PermissionReadWrite, ProvisionEnabled: true, - ProvisionUsers: []*User{}, - ProvisionAccess: map[string][]*Grant{}, + Users: []*User{}, + Access: map[string][]*Grant{}, } a, err := NewManager(conf) require.Nil(t, err) @@ -1210,10 +1210,10 @@ func TestManager_DoNotUpdateNonProvisionedUsers(t *testing.T) { // Re-open the DB (second app start) require.Nil(t, a.db.Close()) - conf.ProvisionUsers = []*User{ + conf.Users = []*User{ {Name: "philuser", Hash: "$2a$10$AAAU21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C", Role: RoleAdmin}, } - conf.ProvisionAccess = map[string][]*Grant{} + conf.Access = map[string][]*Grant{} a, err = NewManager(conf) require.Nil(t, err) From 2578236d8d156b1608afe9ad33a41187f5949eec Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sun, 27 Jul 2025 17:10:37 +0200 Subject: [PATCH 09/22] Docs --- docs/config.md | 60 +++++++++++++++++++++++++++++++++++++++++++---- server/server.yml | 6 ++--- 2 files changed, 59 insertions(+), 7 deletions(-) diff --git a/docs/config.md b/docs/config.md index be15c9fc..564478f7 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 @@ -195,12 +196,20 @@ To set up auth, simply **configure the following two options**: * `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`. -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 let 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. ### 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)). @@ -223,10 +232,45 @@ ntfy user change-role phil admin # Make user phil an admin ntfy user change-tier phil pro # Change phil's tier to "pro" ``` +#### 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 Ansible without manually editing the database. + +The `auth-users` option is a list of users that are automatically created when the server starts. 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-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_USERS='phil:$2a$10$YLiO8U21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C:admin,ben:$2a$10$NKbrNb7HPMjtQXWJ0f1pouw03LDLT/WzlO9VAv44x84bRCkh19h6m:user' + ``` + +The bcrypt 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. +#### ACL entries via the CLI The ACL can be displayed or modified with the `ntfy access` command: ``` @@ -282,6 +326,14 @@ 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 +Alternatively to the `ntfy access` command + ++# - auth-access is a list of access control entries that are automatically created when the server starts. +# Each entry is in the format "::", e.g. "phil:mytopic:rw" or "phil:phil-*:rw". +# + + ### 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 diff --git a/server/server.yml b/server/server.yml index 02af7383..0d748640 100644 --- a/server/server.yml +++ b/server/server.yml @@ -82,9 +82,9 @@ # 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 "::", 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. +# - auth-users is a list of users that are automatically created when the server starts. +# Each entry is in the format "::", e.g. "phil:$2a$10$YLiO8U21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C:user" +# - auth-access is a list of access control entries that are automatically created when the server starts. # Each entry is in the format "::", e.g. "phil:mytopic:rw" or "phil:phil-*:rw". # # Debian/RPM package users: From 0e672286054d4623feb9deb718b3ab1b4e794d8a Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sun, 27 Jul 2025 17:18:06 +0200 Subject: [PATCH 10/22] Docs --- docs/config.md | 33 +++++++++++++++++++++++++++++---- 1 file changed, 29 insertions(+), 4 deletions(-) diff --git a/docs/config.md b/docs/config.md index 564478f7..47dbb923 100644 --- a/docs/config.md +++ b/docs/config.md @@ -327,12 +327,37 @@ to topic `garagedoor` and all topics starting with the word `alerts` (wildcards) (called `*`/`everyone`) only have read access to the `announcements` and `server-stats` topics. #### ACL entries via the config -Alternatively to the `ntfy access` command +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). -+# - auth-access is a list of access control entries that are automatically created when the server starts. -# Each entry is in the format "::", e.g. "phil:mytopic:rw" or "phil:phil-*:rw". -# +The `auth-access` option is a list of access control entries that are automatically created when the server starts. +Each entry is defined in the format `::`. +Here's an example with several ACL entries: + +=== "Declarative ACL entries in /etc/ntfy/server.yml" + ``` yaml + 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_ACCESS='phil:mytopic:rw,ben:alerts-*:rw,ben:system-logs:ro,*:announcements:ro' + ``` + +The `` can be any existing user, 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 ### Access tokens In addition to username/password auth, ntfy also provides authentication via access tokens. Access tokens are useful From 07e9670a0966d16aca7967cc269a14b5bf4a13c3 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sun, 27 Jul 2025 22:33:29 +0200 Subject: [PATCH 11/22] Fix bug in test --- cmd/access.go | 4 +-- docs/releases.md | 3 +- user/manager.go | 27 ++++++++++++---- user/manager_test.go | 77 ++++++++++++++++++++++++++++++++++++-------- 4 files changed, 88 insertions(+), 23 deletions(-) diff --git a/cmd/access.go b/cmd/access.go index 10247b5f..f2916f51 100644 --- a/cmd/access.go +++ b/cmd/access.go @@ -197,7 +197,7 @@ func showUsers(c *cli.Context, manager *user.Manager, users []*user.User) error } provisioned := "" if u.Provisioned { - provisioned = ", provisioned user" + provisioned = ", server config" } fmt.Fprintf(c.App.ErrWriter, "user %s (role: %s, tier: %s%s)\n", u.Name, u.Role, tier, provisioned) if u.Role == user.RoleAdmin { @@ -206,7 +206,7 @@ func showUsers(c *cli.Context, manager *user.Manager, users []*user.User) error for _, grant := range grants { grantProvisioned := "" if grant.Provisioned { - grantProvisioned = ", provisioned access entry" + grantProvisioned = " (server config)" } if grant.Permission.IsReadWrite() { fmt.Fprintf(c.App.ErrWriter, "- read-write access to topic %s%s\n", grant.TopicPattern, grantProvisioned) diff --git a/docs/releases.md b/docs/releases.md index 6171dcff..4f79f544 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -1456,7 +1456,8 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release **Features:** -* Enhanced JSON webhook support via [pre-defined](publish.md#pre-defined-templates) and [custom templates](publish.md#custom-templates) ([#1390](https://github.com/binwiederhier/ntfy/pull/1390)) +* [Declarative users and ACL entries](config.md#users-and-roles) ([#464](https://github.com/binwiederhier/ntfy/issues/464), [#1384](https://github.com/binwiederhier/ntfy/pull/1384), thanks to [pinpox](https://github.com/pinpox) for reporting, to [@wunter8](https://github.com/wunter8) for reviewing) +* [Pre-defined templates](publish.md#pre-defined-templates) and [custom templates](publish.md#custom-templates) for enhanced JSON webhook support ([#1390](https://github.com/binwiederhier/ntfy/pull/1390)) * Support of advanced [template functions](publish.md#template-functions) based on the [Sprig](https://github.com/Masterminds/sprig) library ([#1121](https://github.com/binwiederhier/ntfy/issues/1121), thanks to [@davidatkinsondoyle](https://github.com/davidatkinsondoyle) for reporting, to [@wunter8](https://github.com/wunter8) for implementing, and to the Sprig team for their work) ### ntfy Android app v1.16.1 (UNRELEASED) diff --git a/user/manager.go b/user/manager.go index 5418f534..36a22dd9 100644 --- a/user/manager.go +++ b/user/manager.go @@ -1484,19 +1484,25 @@ func (a *Manager) allowAccessTx(tx *sql.Tx, username string, topicPattern string // ResetAccess removes an access control list entry for a specific username/topic, or (if topic is // empty) for an entire user. The parameter topicPattern may include wildcards (*). func (a *Manager) ResetAccess(username string, topicPattern string) error { + return execTx(a.db, func(tx *sql.Tx) error { + return a.resetAccessTx(tx, username, topicPattern) + }) +} + +func (a *Manager) resetAccessTx(tx *sql.Tx, username string, topicPattern string) error { if !AllowedUsername(username) && username != Everyone && username != "" { return ErrInvalidArgument } else if !AllowedTopicPattern(topicPattern) && topicPattern != "" { return ErrInvalidArgument } if username == "" && topicPattern == "" { - _, err := a.db.Exec(deleteAllAccessQuery, username) + _, err := tx.Exec(deleteAllAccessQuery, username) return err } else if topicPattern == "" { - _, err := a.db.Exec(deleteUserAccessQuery, username, username) + _, err := tx.Exec(deleteUserAccessQuery, username, username) return err } - _, err := a.db.Exec(deleteTopicAccessQuery, username, username, toSQLWildcard(topicPattern)) + _, err := tx.Exec(deleteTopicAccessQuery, username, username, toSQLWildcard(topicPattern)) return err } @@ -1734,7 +1740,18 @@ func (a *Manager) maybeProvisionUsersAndAccess() error { return err } for username, grants := range a.config.Access { + user, exists := util.Find(a.config.Users, func(u *User) bool { + return u.Name == username + }) + if !exists && username != Everyone { + return fmt.Errorf("user %s is not a provisioned user, refusing to add ACL entry", username) + } else if user != nil && user.Role == RoleAdmin { + return fmt.Errorf("adding access control entries is not allowed for admin roles for user %s", username) + } for _, grant := range grants { + if err := a.resetAccessTx(tx, username, grant.TopicPattern); err != nil { + return fmt.Errorf("failed to reset access for user %s and topic %s: %v", username, grant.TopicPattern, err) + } if err := a.allowAccessTx(tx, username, grant.TopicPattern, grant.Permission, true); err != nil { return err } @@ -1951,10 +1968,8 @@ func execTx(db *sql.DB, f func(tx *sql.Tx) error) error { if err != nil { return err } + defer tx.Rollback() if err := f(tx); err != nil { - if e := tx.Rollback(); e != nil { - return err - } return err } return tx.Commit() diff --git a/user/manager_test.go b/user/manager_test.go index d55726a3..297263e9 100644 --- a/user/manager_test.go +++ b/user/manager_test.go @@ -1193,37 +1193,86 @@ func TestManager_WithProvisionedUsers(t *testing.T) { require.Equal(t, "*", users[1].Name) } -func TestManager_DoNotUpdateNonProvisionedUsers(t *testing.T) { +func TestManager_UpdateNonProvisionedUsersToProvisionedUsers(t *testing.T) { f := filepath.Join(t.TempDir(), "user.db") conf := &Config{ Filename: f, DefaultAccess: PermissionReadWrite, ProvisionEnabled: true, Users: []*User{}, - Access: map[string][]*Grant{}, + Access: map[string][]*Grant{ + Everyone: { + {TopicPattern: "food", Permission: PermissionRead}, + }, + }, } a, err := NewManager(conf) require.Nil(t, err) // Manually add user require.Nil(t, a.AddUser("philuser", "manual", RoleUser, false)) + require.Nil(t, a.AllowAccess("philuser", "stats", PermissionReadWrite)) + require.Nil(t, a.AllowAccess("philuser", "food", PermissionReadWrite)) - // Re-open the DB (second app start) - require.Nil(t, a.db.Close()) - conf.Users = []*User{ - {Name: "philuser", Hash: "$2a$10$AAAU21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C", Role: RoleAdmin}, - } - conf.Access = 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, "philuser", users[0].Name) - require.Equal(t, RoleUser, users[0].Role) // Should not have been updated - require.NotEqual(t, "$2a$10$AAAU21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C", users[0].Hash) + require.Equal(t, RoleUser, users[0].Role) + require.False(t, users[0].Provisioned) // Manually added + + grants, err := a.Grants("philuser") + require.Nil(t, err) + require.Equal(t, 2, len(grants)) + require.Equal(t, "stats", grants[0].TopicPattern) + require.Equal(t, PermissionReadWrite, grants[0].Permission) + require.False(t, grants[0].Provisioned) // Manually added + require.Equal(t, "food", grants[1].TopicPattern) + require.Equal(t, PermissionReadWrite, grants[1].Permission) + require.False(t, grants[1].Provisioned) // Manually added + + grants, err = a.Grants(Everyone) + require.Nil(t, err) + require.Equal(t, 1, len(grants)) + require.Equal(t, "food", grants[0].TopicPattern) + require.Equal(t, PermissionRead, grants[0].Permission) + require.True(t, grants[0].Provisioned) // Provisioned entry + + // Re-open the DB (second app start) + require.Nil(t, a.db.Close()) + conf.Users = []*User{ + {Name: "philuser", Hash: "$2a$10$AAAU21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C", Role: RoleUser}, + } + conf.Access = map[string][]*Grant{ + "philuser": { + {TopicPattern: "stats", Permission: PermissionReadWrite}, + }, + } + a, err = NewManager(conf) + require.Nil(t, err) + + // Check that the user was "upgraded" to a provisioned user + users, err = a.Users() + require.Nil(t, err) + require.Len(t, users, 2) + require.Equal(t, "philuser", users[0].Name) + require.Equal(t, RoleUser, users[0].Role) + require.Equal(t, "$2a$10$AAAU21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C", users[0].Hash) + require.True(t, users[0].Provisioned) // Updated to provisioned! + + grants, err = a.Grants("philuser") + require.Nil(t, err) + require.Equal(t, 2, len(grants)) + require.Equal(t, "stats", grants[0].TopicPattern) + require.Equal(t, PermissionReadWrite, grants[0].Permission) + require.True(t, grants[0].Provisioned) // Updated to provisioned! + require.Equal(t, "food", grants[1].TopicPattern) + require.Equal(t, PermissionReadWrite, grants[1].Permission) + require.False(t, grants[1].Provisioned) // Manually added grants stay! + + grants, err = a.Grants(Everyone) + require.Nil(t, err) + require.Empty(t, grants) } func TestToFromSQLWildcard(t *testing.T) { From 149c13e9d89cede63e105990e6f6fd88d8cf22de Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sun, 27 Jul 2025 22:38:12 +0200 Subject: [PATCH 12/22] Update config to reference declarative users --- docs/config.md | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/docs/config.md b/docs/config.md index 47dbb923..587e8844 100644 --- a/docs/config.md +++ b/docs/config.md @@ -393,23 +393,17 @@ Once an access token is created, you can **use it to authenticate against the nt 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`: +The easiest way to configure a private instance is to set `auth-default-access` to `deny-all` in the `server.yml`, +and to configure a single admin user in the `auth-users` section (see [Users via the config](#users-via-the-config)). === "/etc/ntfy/server.yml" ``` yaml auth-file: "/var/lib/ntfy/user.db" auth-default-access: "deny-all" + auth-users: + - "phil:$2a$10$YLiO8U21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C:admin" ``` -After that, simply create an `admin` user: - -``` -$ ntfy user add --role=admin phil -password: mypass -confirm: mypass -user phil added with role admin -``` - Once you've done that, you can publish and subscribe using [Basic Auth](https://en.wikipedia.org/wiki/Basic_access_authentication) with the given username/password. Be sure to use HTTPS to avoid eavesdropping and exposing your password. Here's a simple example: From 23ec7702fce5690d1c0f55d61e8e02d5ba93d43a Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Thu, 31 Jul 2025 07:08:35 +0200 Subject: [PATCH 13/22] Add "auth-tokens" --- cmd/serve.go | 56 ++++++++++-- cmd/token.go | 29 ++++-- server/config.go | 1 + server/server.go | 1 + server/server.yml | 2 + server/server_account.go | 2 +- server/server_account_test.go | 2 +- user/manager.go | 165 ++++++++++++++++++++++++---------- user/manager_test.go | 69 +++++++++----- user/types.go | 24 +++-- 10 files changed, 263 insertions(+), 88 deletions(-) diff --git a/cmd/serve.go b/cmd/serve.go index dc503ccc..36bef4bd 100644 --- a/cmd/serve.go +++ b/cmd/serve.go @@ -50,6 +50,7 @@ var flagsServe = append( 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)"}), @@ -158,6 +159,7 @@ func execServe(c *cli.Context) error { 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") @@ -361,6 +363,10 @@ func execServe(c *cli.Context) error { if err != nil { return err } + authTokens, err := parseTokens(authUsers, authTokensRaw) + if err != nil { + return err + } // Special case: Unset default if listenHTTP == "-" { @@ -418,6 +424,7 @@ func execServe(c *cli.Context) error { conf.AuthDefault = authDefault conf.AuthUsers = authUsers conf.AuthAccess = authAccess + conf.AuthTokens = authTokens conf.AttachmentCacheDir = attachmentCacheDir conf.AttachmentTotalSizeLimit = attachmentTotalSizeLimit conf.AttachmentFileSizeLimit = attachmentFileSizeLimit @@ -532,7 +539,7 @@ func parseIPHostPrefix(host string) (prefixes []netip.Prefix, err error) { } func parseUsers(usersRaw []string) ([]*user.User, error) { - provisionUsers := make([]*user.User, 0) + users := make([]*user.User, 0) for _, userLine := range usersRaw { parts := strings.Split(userLine, ":") if len(parts) != 3 { @@ -548,19 +555,19 @@ func parseUsers(usersRaw []string) ([]*user.User, 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) } - provisionUsers = append(provisionUsers, &user.User{ + users = append(users, &user.User{ Name: username, Hash: passwordHash, Role: role, Provisioned: true, }) } - return provisionUsers, nil + return users, nil } -func parseAccess(provisionUsers []*user.User, provisionAccessRaw []string) (map[string][]*user.Grant, error) { +func parseAccess(users []*user.User, accessRaw []string) (map[string][]*user.Grant, error) { access := make(map[string][]*user.Grant) - for _, accessLine := range provisionAccessRaw { + 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) @@ -569,7 +576,7 @@ func parseAccess(provisionUsers []*user.User, provisionAccessRaw []string) (map[ if username == userEveryone { username = user.Everyone } - provisionUser, exists := util.Find(provisionUsers, func(u *user.User) bool { + u, exists := util.Find(users, func(u *user.User) bool { return u.Name == username }) if username != user.Everyone { @@ -577,7 +584,7 @@ func parseAccess(provisionUsers []*user.User, provisionAccessRaw []string) (map[ 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 provisionUser.Role != user.RoleUser { + } 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) } } @@ -601,6 +608,41 @@ func parseAccess(provisionUsers []*user.User, provisionAccessRaw []string) (map[ 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.AllowedToken(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, + }) + } + return tokens, nil +} + func reloadLogLevel(inputSource altsrc.InputSourceContext) error { newLevelStr, err := inputSource.String("log-level") if err != nil { diff --git a/cmd/token.go b/cmd/token.go index cb92a130..25399c89 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,12 +121,12 @@ 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 } @@ -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 @@ -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 @@ -191,7 +200,7 @@ func execTokenList(c *cli.Context) error { usersWithTokens++ fmt.Fprintf(c.App.ErrWriter, "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,7 +209,10 @@ 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.ErrWriter, "- %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 { @@ -208,3 +220,8 @@ func execTokenList(c *cli.Context) error { } return nil } + +func execTokenGenerate(c *cli.Context) error { + fmt.Println(user.GenerateToken()) + return nil +} diff --git a/server/config.go b/server/config.go index 99d829b2..6a7c4cee 100644 --- a/server/config.go +++ b/server/config.go @@ -97,6 +97,7 @@ type Config struct { AuthDefault user.Permission AuthUsers []*user.User AuthAccess map[string][]*user.Grant + AuthTokens map[string][]*user.Token AuthBcryptCost int AuthStatsQueueWriterInterval time.Duration AttachmentCacheDir string diff --git a/server/server.go b/server/server.go index 55fa3af7..05b5b63a 100644 --- a/server/server.go +++ b/server/server.go @@ -203,6 +203,7 @@ func New(conf *Config) (*Server, error) { ProvisionEnabled: true, // Enable provisioning of users and access Users: conf.AuthUsers, Access: conf.AuthAccess, + Tokens: conf.AuthTokens, BcryptCost: conf.AuthBcryptCost, QueueWriterInterval: conf.AuthStatsQueueWriterInterval, } diff --git a/server/server.yml b/server/server.yml index 0d748640..2a623bc4 100644 --- a/server/server.yml +++ b/server/server.yml @@ -86,6 +86,8 @@ # Each entry is in the format "::", e.g. "phil:$2a$10$YLiO8U21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C:user" # - auth-access is a list of access control entries that are automatically created when the server starts. # Each entry is in the format "::", e.g. "phil:mytopic:rw" or "phil:phil-*:rw". +# - auth-tokens is a list of access tokens that are automatically created when the server starts. +# Each entry is in the format ":[: