This commit is contained in:
Philipp C. Heckel 2025-07-13 19:05:06 +02:00 committed by GitHub
commit 2bb8bebcb1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 158 additions and 31 deletions

View file

@ -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-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-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.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-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-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)"}), 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") authFile := c.String("auth-file")
authStartupQueries := c.String("auth-startup-queries") authStartupQueries := c.String("auth-startup-queries")
authDefaultAccess := c.String("auth-default-access") authDefaultAccess := c.String("auth-default-access")
authProvisionedUsersRaw := c.StringSlice("auth-provisioned-users")
//authProvisionedAccessRaw := c.StringSlice("auth-provisioned-access")
attachmentCacheDir := c.String("attachment-cache-dir") attachmentCacheDir := c.String("attachment-cache-dir")
attachmentTotalSizeLimitStr := c.String("attachment-total-size-limit") attachmentTotalSizeLimitStr := c.String("attachment-total-size-limit")
attachmentFileSizeLimitStr := c.String("attachment-file-size-limit") attachmentFileSizeLimitStr := c.String("attachment-file-size-limit")
@ -346,11 +349,33 @@ func execServe(c *cli.Context) error {
webRoot = "/" + webRoot webRoot = "/" + webRoot
} }
// Default auth permissions // Convert default auth permission, read provisioned users
authDefault, err := user.ParsePermission(authDefaultAccess) authDefault, err := user.ParsePermission(authDefaultAccess)
if err != nil { if err != nil {
return errors.New("if set, auth-default-access must start set to 'read-write', 'read-only', 'write-only' or 'deny-all'") 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 // Special case: Unset default
if listenHTTP == "-" { if listenHTTP == "-" {
@ -406,6 +431,8 @@ func execServe(c *cli.Context) error {
conf.AuthFile = authFile conf.AuthFile = authFile
conf.AuthStartupQueries = authStartupQueries conf.AuthStartupQueries = authStartupQueries
conf.AuthDefault = authDefault conf.AuthDefault = authDefault
conf.AuthProvisionedUsers = authProvisionedUsers
conf.AuthProvisionedAccess = nil // FIXME
conf.AttachmentCacheDir = attachmentCacheDir conf.AttachmentCacheDir = attachmentCacheDir
conf.AttachmentTotalSizeLimit = attachmentTotalSizeLimit conf.AttachmentTotalSizeLimit = attachmentTotalSizeLimit
conf.AttachmentFileSizeLimit = attachmentFileSizeLimit conf.AttachmentFileSizeLimit = attachmentFileSizeLimit

View file

@ -94,7 +94,6 @@ Example:
You may set the NTFY_PASSWORD environment variable to pass the new password or NTFY_PASSWORD_HASH to pass 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. 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 { if err != nil {
return err 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) return fmt.Errorf("user %s does not exist", username)
} }
if err := manager.RemoveUser(username); err != nil { if err := manager.RemoveUser(username); err != nil {
@ -251,7 +250,7 @@ func execUserChangePass(c *cli.Context) error {
if err != nil { if err != nil {
return err 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) return fmt.Errorf("user %s does not exist", username)
} }
if password == "" { if password == "" {
@ -279,7 +278,7 @@ func execUserChangeRole(c *cli.Context) error {
if err != nil { if err != nil {
return err 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) return fmt.Errorf("user %s does not exist", username)
} }
if err := manager.ChangeRole(username, role); err != nil { if err := manager.ChangeRole(username, role); err != nil {
@ -303,7 +302,7 @@ func execUserChangeTier(c *cli.Context) error {
if err != nil { if err != nil {
return err 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) return fmt.Errorf("user %s does not exist", username)
} }
if tier == tierReset { if tier == tierReset {
@ -345,7 +344,16 @@ func createUserManager(c *cli.Context) (*user.Manager, error) {
if err != nil { 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 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) { func readPasswordAndConfirm(c *cli.Context) (string, error) {

View file

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

View file

@ -189,7 +189,16 @@ func New(conf *Config) (*Server, error) {
} }
var userManager *user.Manager var userManager *user.Manager
if conf.AuthFile != "" { 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 { if err != nil {
return nil, err return nil, err
} }

View file

@ -441,36 +441,55 @@ var (
// Manager is an implementation of Manager. It stores users and access control list // Manager is an implementation of Manager. It stores users and access control list
// in a SQLite database. // in a SQLite database.
type Manager struct { type Manager struct {
db *sql.DB config *Config
defaultAccess Permission // Default permission if no ACL matches db *sql.DB
statsQueue map[string]*Stats // "Queue" to asynchronously write user stats to the database (UserID -> Stats) 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) 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
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) var _ Auther = (*Manager)(nil)
// NewManager creates a new Manager instance // NewManager creates a new Manager instance
func NewManager(filename, startupQueries string, defaultAccess Permission, bcryptCost int, queueWriterInterval time.Duration) (*Manager, error) { func NewManager(config *Config) (*Manager, error) {
db, err := sql.Open("sqlite3", filename) // 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 { if err != nil {
return nil, err return nil, err
} }
if err := setupDB(db); err != nil { if err := setupDB(db); err != nil {
return nil, err return nil, err
} }
if err := runStartupQueries(db, startupQueries); err != nil { if err := runStartupQueries(db, config.StartupQueries); err != nil {
return nil, err return nil, err
} }
manager := &Manager{ manager := &Manager{
db: db, db: db,
defaultAccess: defaultAccess, config: config,
statsQueue: make(map[string]*Stats), statsQueue: make(map[string]*Stats),
tokenQueue: make(map[string]*TokenUpdate), tokenQueue: make(map[string]*TokenUpdate),
bcryptCost: bcryptCost,
} }
go manager.asyncQueueWriter(queueWriterInterval) if err := manager.provisionUsers(); err != nil {
return nil, err
}
go manager.asyncQueueWriter(config.QueueWriterInterval)
return manager, nil return manager, nil
} }
@ -843,7 +862,7 @@ func (a *Manager) Authorize(user *User, topic string, perm Permission) error {
} }
defer rows.Close() defer rows.Close()
if !rows.Next() { if !rows.Next() {
return a.resolvePerms(a.defaultAccess, perm) return a.resolvePerms(a.config.DefaultAccess, perm)
} }
var read, write bool var read, write bool
if err := rows.Scan(&read, &write); err != nil { 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 { if hashed {
hash = []byte(password) hash = []byte(password)
} else { } else {
hash, err = bcrypt.GenerateFromPassword([]byte(password), a.bcryptCost) hash, err = bcrypt.GenerateFromPassword([]byte(password), a.config.BcryptCost)
if err != nil { if err != nil {
return err return err
} }
@ -1205,7 +1224,7 @@ func (a *Manager) ChangePassword(username, password string, hashed bool) error {
if hashed { if hashed {
hash = []byte(password) hash = []byte(password)
} else { } else {
hash, err = bcrypt.GenerateFromPassword([]byte(password), a.bcryptCost) hash, err = bcrypt.GenerateFromPassword([]byte(password), a.config.BcryptCost)
if err != nil { if err != nil {
return err 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 // DefaultAccess returns the default read/write access if no access control entry matches
func (a *Manager) DefaultAccess() Permission { func (a *Manager) DefaultAccess() Permission {
return a.defaultAccess return a.config.DefaultAccess
} }
// AddTier creates a new tier in the database // AddTier creates a new tier in the database
@ -1505,6 +1524,22 @@ func (a *Manager) Close() error {
return a.db.Close() 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, // toSQLWildcard converts a wildcard string to a SQL wildcard string. It only allows '*' as wildcards,
// and escapes '_', assuming '\' as escape character. // and escapes '_', assuming '\' as escape character.
func toSQLWildcard(s string) string { func toSQLWildcard(s string) string {

View file

@ -731,7 +731,14 @@ func TestManager_Token_MaxCount_AutoDelete(t *testing.T) {
} }
func TestManager_EnqueueStats_ResetStats(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, err)
require.Nil(t, a.AddUser("ben", "ben", RoleUser, false)) 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) { 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, err)
require.Nil(t, a.AddUser("ben", "ben", RoleUser, false)) 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) { 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, err)
require.Nil(t, a.AddUser("ben", "ben", RoleUser, false)) 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)) 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) { func TestToFromSQLWildcard(t *testing.T) {
require.Equal(t, "up%", toSQLWildcard("up*")) require.Equal(t, "up%", toSQLWildcard("up*"))
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 { 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) require.Nil(t, err)
return a return a
} }