mirror of
https://github.com/binwiederhier/ntfy.git
synced 2025-07-20 10:04:08 +00:00
Merge c0b5151bae
into 81a486adc1
This commit is contained in:
commit
2bb8bebcb1
6 changed files with 158 additions and 31 deletions
29
cmd/serve.go
29
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
|
||||
|
|
20
cmd/user.go
20
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) {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue