diff --git a/cmd/serve.go b/cmd/serve.go index ef4d98d5..abd9ac06 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-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)"}), @@ -157,6 +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") + 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") @@ -346,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 == "-" { @@ -406,6 +431,8 @@ func execServe(c *cli.Context) error { conf.AuthFile = authFile conf.AuthStartupQueries = authStartupQueries conf.AuthDefault = authDefault + 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 e6867b11..7519438c 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. - `, }, { @@ -225,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 { @@ -251,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 == "" { @@ -279,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 { @@ -303,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 { @@ -345,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 59b11c16..c163614f 100644 --- a/server/config.go +++ b/server/config.go @@ -93,6 +93,8 @@ type Config struct { AuthFile string AuthStartupQueries string AuthDefault user.Permission + AuthProvisionedUsers []*user.User + AuthProvisionedAccess map[string][]*user.Grant AuthBcryptCost int AuthStatsQueueWriterInterval time.Duration AttachmentCacheDir string diff --git a/server/server.go b/server/server.go index bfa7eb6b..cba9b181 100644 --- a/server/server.go +++ b/server/server.go @@ -189,7 +189,16 @@ 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, + ProvisionedUsers: conf.AuthProvisionedUsers, + ProvisionedAccess: conf.AuthProvisionedAccess, + 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..8932f34a 100644 --- a/user/manager.go +++ b/user/manager.go @@ -441,36 +441,55 @@ 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 // 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 + QueueWriterInterval time.Duration // Interval for the async queue writer to flush stats and token updates to the database + BcryptCost int // Cost of generated passwords; lowering makes testing faster } var _ Auther = (*Manager)(nil) // NewManager creates a new Manager instance -func NewManager(filename, startupQueries string, defaultAccess Permission, bcryptCost int, queueWriterInterval time.Duration) (*Manager, error) { - db, err := sql.Open("sqlite3", filename) +func NewManager(config *Config) (*Manager, error) { + // Set defaults + if config.BcryptCost <= 0 { + config.BcryptCost = DefaultUserPasswordBcryptCost + } + if config.QueueWriterInterval.Seconds() <= 0 { + config.QueueWriterInterval = DefaultUserStatsQueueWriterInterval + } + // Open DB and run setup queries + db, err := sql.Open("sqlite3", config.Filename) if err != nil { return nil, err } if err := setupDB(db); err != nil { return nil, err } - if err := runStartupQueries(db, startupQueries); err != nil { + if err := runStartupQueries(db, config.StartupQueries); err != nil { return nil, err } manager := &Manager{ - db: db, - defaultAccess: defaultAccess, - statsQueue: make(map[string]*Stats), - tokenQueue: make(map[string]*TokenUpdate), - bcryptCost: bcryptCost, + db: db, + config: config, + statsQueue: make(map[string]*Stats), + tokenQueue: make(map[string]*TokenUpdate), } - go manager.asyncQueueWriter(queueWriterInterval) + if err := manager.provisionUsers(); err != nil { + return nil, err + } + go manager.asyncQueueWriter(config.QueueWriterInterval) return manager, nil } @@ -843,7 +862,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 +892,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 +1224,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 +1406,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 @@ -1505,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 }