diff --git a/server/server.go b/server/server.go index 747c8a1..2aacf37 100644 --- a/server/server.go +++ b/server/server.go @@ -355,6 +355,8 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visit return s.handleAccountSettingsChange(w, r, v) } else if r.Method == http.MethodPost && r.URL.Path == accountSubscriptionPath { return s.handleAccountSubscriptionAdd(w, r, v) + } else if r.Method == http.MethodPatch && accountSubscriptionSingleRegex.MatchString(r.URL.Path) { + return s.handleAccountSubscriptionChange(w, r, v) } else if r.Method == http.MethodDelete && accountSubscriptionSingleRegex.MatchString(r.URL.Path) { return s.handleAccountSubscriptionDelete(w, r, v) } else if r.Method == http.MethodGet && r.URL.Path == matrixPushPath { diff --git a/server/server_account.go b/server/server_account.go index 1f9d849..7f3d0d8 100644 --- a/server/server_account.go +++ b/server/server_account.go @@ -5,6 +5,7 @@ import ( "errors" "heckel.io/ntfy/user" "heckel.io/ntfy/util" + "io" "net/http" ) @@ -244,40 +245,75 @@ func (s *Server) handleAccountSubscriptionAdd(w http.ResponseWriter, r *http.Req if v.user == nil { return errors.New("no user") } - w.Header().Set("Content-Type", "application/json") - w.Header().Set("Access-Control-Allow-Origin", "*") // FIXME remove this - body, err := util.Peek(r.Body, 4096) // FIXME + newSubscription, err := readJSONBody[user.Subscription](r.Body) if err != nil { return err } - defer r.Body.Close() - var newSubscription user.Subscription - if err := json.NewDecoder(body).Decode(&newSubscription); err != nil { - return err - } if v.user.Prefs == nil { v.user.Prefs = &user.Prefs{} } newSubscription.ID = "" // Client cannot set ID for _, subscription := range v.user.Prefs.Subscriptions { if newSubscription.BaseURL == subscription.BaseURL && newSubscription.Topic == subscription.Topic { - newSubscription = *subscription + newSubscription = subscription break } } if newSubscription.ID == "" { newSubscription.ID = util.RandomString(16) - v.user.Prefs.Subscriptions = append(v.user.Prefs.Subscriptions, &newSubscription) + v.user.Prefs.Subscriptions = append(v.user.Prefs.Subscriptions, newSubscription) if err := s.userManager.ChangeSettings(v.user); err != nil { return err } } + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Access-Control-Allow-Origin", "*") // FIXME remove this if err := json.NewEncoder(w).Encode(newSubscription); err != nil { return err } return nil } +func (s *Server) handleAccountSubscriptionChange(w http.ResponseWriter, r *http.Request, v *visitor) error { + if v.user == nil { + return errors.New("no user") // FIXME s.ensureUser + } + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Access-Control-Allow-Origin", "*") // FIXME remove this + matches := accountSubscriptionSingleRegex.FindStringSubmatch(r.URL.Path) + if len(matches) != 2 { + return errHTTPInternalErrorInvalidFilePath // FIXME + } + updatedSubscription, err := readJSONBody[user.Subscription](r.Body) + if err != nil { + return err + } + subscriptionID := matches[1] + if v.user.Prefs == nil || v.user.Prefs.Subscriptions == nil { + return errHTTPNotFound + } + var subscription *user.Subscription + for _, sub := range v.user.Prefs.Subscriptions { + if sub.ID == subscriptionID { + sub.DisplayName = updatedSubscription.DisplayName + subscription = sub + break + } + } + if subscription == nil { + return errHTTPNotFound + } + if err := s.userManager.ChangeSettings(v.user); err != nil { + return err + } + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Access-Control-Allow-Origin", "*") // FIXME remove this + if err := json.NewEncoder(w).Encode(subscription); err != nil { + return err + } + return nil +} + func (s *Server) handleAccountSubscriptionDelete(w http.ResponseWriter, r *http.Request, v *visitor) error { if v.user == nil { return errors.New("no user") @@ -306,3 +342,16 @@ func (s *Server) handleAccountSubscriptionDelete(w http.ResponseWriter, r *http. } return nil } + +func readJSONBody[T any](body io.ReadCloser) (*T, error) { + body, err := util.Peek(body, 4096) + if err != nil { + return nil, err + } + defer body.Close() + var obj T + if err := json.NewDecoder(body).Decode(&obj); err != nil { + return nil, err + } + return &obj, nil +} diff --git a/user/manager.go b/user/manager.go index a8de6e5..70b3dc8 100644 --- a/user/manager.go +++ b/user/manager.go @@ -1,176 +1,592 @@ -// Package auth deals with authentication and authorization against topics package user import ( + "database/sql" + "encoding/json" "errors" - "regexp" + "fmt" + _ "github.com/mattn/go-sqlite3" // SQLite driver + "golang.org/x/crypto/bcrypt" + "heckel.io/ntfy/log" + "heckel.io/ntfy/util" + "strings" + "sync" + "time" ) -// Manager is a generic interface to implement password and token based authentication and authorization -type Manager interface { - // Authenticate checks username and password and returns a user if correct. The method - // returns in constant-ish time, regardless of whether the user exists or the password is - // correct or incorrect. - Authenticate(username, password string) (*User, error) - - AuthenticateToken(token string) (*User, error) - CreateToken(user *User) (*Token, error) - ExtendToken(user *User) (*Token, error) - RemoveToken(user *User) error - RemoveExpiredTokens() error - ChangeSettings(user *User) error - EnqueueStats(user *User) - - // Authorize returns nil if the given user has access to the given topic using the desired - // permission. The user param may be nil to signal an anonymous user. - Authorize(user *User, topic string, perm Permission) error - - // AddUser adds a user with the given username, password and role. The password should be hashed - // before it is stored in a persistence layer. - AddUser(username, password string, role Role) error - - // 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. - RemoveUser(username string) error - - // Users returns a list of users. It always also returns the Everyone user ("*"). - Users() ([]*User, error) - - // User returns the user with the given username if it exists, or ErrNotFound otherwise. - // You may also pass Everyone to retrieve the anonymous user and its Grant list. - User(username string) (*User, error) - - // ChangePassword changes a user's password - ChangePassword(username, password string) 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. - ChangeRole(username string, role Role) error - - // AllowAccess adds or updates an entry in th access control list for a specific user. It controls - // read/write access to a topic. The parameter topicPattern may include wildcards (*). - AllowAccess(username string, topicPattern string, read bool, write bool) error - - // 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 (*). - ResetAccess(username string, topicPattern string) error - - // DefaultAccess returns the default read/write access if no access control entry matches - DefaultAccess() (read bool, write bool) -} - -// User is a struct that represents a user -type User struct { - Name string - Hash string // password hash (bcrypt) - Token string // Only set if token was used to log in - Role Role - Grants []Grant - Prefs *Prefs - Plan *Plan - Stats *Stats -} - -type Token struct { - Value string - Expires int64 -} - -type Prefs struct { - Language string `json:"language,omitempty"` - Notification *NotificationPrefs `json:"notification,omitempty"` - Subscriptions []*Subscription `json:"subscriptions,omitempty"` -} - -type PlanCode string - const ( - PlanUnlimited = PlanCode("unlimited") - PlanDefault = PlanCode("default") - PlanNone = PlanCode("none") + tokenLength = 32 + bcryptCost = 10 + intentionalSlowDownHash = "$2a$10$YFCQvqQDwIIwnJM1xkAYOeih0dg17UVGanaTStnrSzC8NCWxcLDwy" // Cost should match bcryptCost + userStatsQueueWriterInterval = 33 * time.Second + userTokenExpiryDuration = 72 * time.Hour ) -type Plan struct { - Code string `json:"name"` - Upgradable bool `json:"upgradable"` - MessagesLimit int64 `json:"messages_limit"` - EmailsLimit int64 `json:"emails_limit"` - AttachmentFileSizeLimit int64 `json:"attachment_file_size_limit"` - AttachmentTotalSizeLimit int64 `json:"attachment_total_size_limit"` -} - -type Subscription struct { - ID string `json:"id"` - BaseURL string `json:"base_url"` - Topic string `json:"topic"` -} - -type NotificationPrefs struct { - Sound string `json:"sound,omitempty"` - MinPriority int `json:"min_priority,omitempty"` - DeleteAfter int `json:"delete_after,omitempty"` -} - -type Stats struct { - Messages int64 - Emails int64 -} - -// Grant is a struct that represents an access control entry to a topic -type Grant struct { - TopicPattern string // May include wildcard (*) - AllowRead bool - AllowWrite bool -} - -// Permission represents a read or write permission to a topic -type Permission int - -// Permissions to a topic +// Manager-related queries const ( - PermissionRead = Permission(1) - PermissionWrite = Permission(2) + createAuthTablesQueries = ` + BEGIN; + CREATE TABLE IF NOT EXISTS plan ( + id INT NOT NULL, + code TEXT NOT NULL, + messages_limit INT NOT NULL, + emails_limit INT NOT NULL, + attachment_file_size_limit INT NOT NULL, + attachment_total_size_limit INT NOT NULL, + PRIMARY KEY (id) + ); + CREATE TABLE IF NOT EXISTS user ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + plan_id INT, + user TEXT NOT NULL, + pass TEXT NOT NULL, + role TEXT NOT NULL, + messages INT NOT NULL DEFAULT (0), + emails INT NOT NULL DEFAULT (0), + settings JSON, + FOREIGN KEY (plan_id) REFERENCES plan (id) + ); + CREATE UNIQUE INDEX idx_user ON user (user); + CREATE TABLE IF NOT EXISTS user_access ( + user_id INT NOT NULL, + topic TEXT NOT NULL, + read INT NOT NULL, + write INT NOT NULL, + PRIMARY KEY (user_id, topic), + FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE + ); + CREATE TABLE IF NOT EXISTS user_token ( + user_id INT NOT NULL, + token TEXT NOT NULL, + expires INT NOT NULL, + PRIMARY KEY (user_id, token), + FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE + ); + CREATE TABLE IF NOT EXISTS schemaVersion ( + id INT PRIMARY KEY, + version INT NOT NULL + ); + INSERT INTO user (id, user, pass, role) VALUES (1, '*', '', 'anonymous') ON CONFLICT (id) DO NOTHING; + COMMIT; + ` + selectUserByNameQuery = ` + SELECT u.user, u.pass, u.role, u.messages, u.emails, u.settings, p.code, p.messages_limit, p.emails_limit, p.attachment_file_size_limit, p.attachment_total_size_limit + FROM user u + LEFT JOIN plan p on p.id = u.plan_id + WHERE user = ? + ` + selectUserByTokenQuery = ` + SELECT u.user, u.pass, u.role, u.messages, u.emails, u.settings, p.code, p.messages_limit, p.emails_limit, p.attachment_file_size_limit, p.attachment_total_size_limit + FROM user u + JOIN user_token t on u.id = t.user_id + LEFT JOIN plan p on p.id = u.plan_id + WHERE t.token = ? + ` + selectTopicPermsQuery = ` + SELECT read, write + FROM user_access + JOIN user ON user.user = '*' OR user.user = ? + WHERE ? LIKE user_access.topic + ORDER BY user.user DESC + ` ) -// Role represents a user's role, either admin or regular user -type Role string - -// User roles +// Manager-related queries const ( - RoleAdmin = Role("admin") - RoleUser = Role("user") - RoleAnonymous = Role("anonymous") + insertUserQuery = `INSERT INTO user (user, pass, role) VALUES (?, ?, ?)` + selectUsernamesQuery = `SELECT user FROM user ORDER BY role, user` + updateUserPassQuery = `UPDATE user SET pass = ? WHERE user = ?` + updateUserRoleQuery = `UPDATE user SET role = ? WHERE user = ?` + updateUserSettingsQuery = `UPDATE user SET settings = ? WHERE user = ?` + updateUserStatsQuery = `UPDATE user SET messages = ?, emails = ? WHERE user = ?` + deleteUserQuery = `DELETE FROM user WHERE user = ?` + + upsertUserAccessQuery = `INSERT INTO user_access (user_id, topic, read, write) VALUES ((SELECT id FROM user WHERE user = ?), ?, ?, ?)` + selectUserAccessQuery = `SELECT topic, read, write FROM user_access WHERE user_id = (SELECT id FROM user WHERE user = ?)` + deleteAllAccessQuery = `DELETE FROM user_access` + deleteUserAccessQuery = `DELETE FROM user_access WHERE user_id = (SELECT id FROM user WHERE user = ?)` + deleteTopicAccessQuery = `DELETE FROM user_access WHERE user_id = (SELECT id FROM user WHERE user = ?) AND topic = ?` + + insertTokenQuery = `INSERT INTO user_token (user_id, token, expires) VALUES ((SELECT id FROM user WHERE user = ?), ?, ?)` + updateTokenExpiryQuery = `UPDATE user_token SET expires = ? WHERE user_id = (SELECT id FROM user WHERE user = ?) AND token = ?` + deleteTokenQuery = `DELETE FROM user_token WHERE user_id = (SELECT id FROM user WHERE user = ?) AND token = ?` + deleteExpiredTokensQuery = `DELETE FROM user_token WHERE expires < ?` + deleteUserTokensQuery = `DELETE FROM user_token WHERE user_id = (SELECT id FROM user WHERE user = ?)` ) -// Everyone is a special username representing anonymous users +// Schema management queries const ( - Everyone = "*" + currentSchemaVersion = 1 + insertSchemaVersion = `INSERT INTO schemaVersion VALUES (1, ?)` + selectSchemaVersionQuery = `SELECT version FROM schemaVersion WHERE id = 1` ) -var ( - allowedUsernameRegex = regexp.MustCompile(`^[-_.@a-zA-Z0-9]+$`) // Does not include Everyone (*) - allowedTopicPatternRegex = regexp.MustCompile(`^[-_*A-Za-z0-9]{1,64}$`) // Adds '*' for wildcards! -) - -// AllowedRole returns true if the given role can be used for new users -func AllowedRole(role Role) bool { - return role == RoleUser || role == RoleAdmin +// SQLiteManager is an implementation of Manager. It stores users and access control list +// in a SQLite database. +type SQLiteManager struct { + db *sql.DB + defaultRead bool + defaultWrite bool + statsQueue map[string]*User // Username -> User, for "unimportant" user updates + mu sync.Mutex } -// AllowedUsername returns true if the given username is valid -func AllowedUsername(username string) bool { - return allowedUsernameRegex.MatchString(username) +var _ Manager = (*SQLiteManager)(nil) + +// NewSQLiteAuthManager creates a new SQLiteManager instance +func NewSQLiteAuthManager(filename string, defaultRead, defaultWrite bool) (*SQLiteManager, error) { + db, err := sql.Open("sqlite3", filename) + if err != nil { + return nil, err + } + if err := setupAuthDB(db); err != nil { + return nil, err + } + manager := &SQLiteManager{ + db: db, + defaultRead: defaultRead, + defaultWrite: defaultWrite, + statsQueue: make(map[string]*User), + } + go manager.userStatsQueueWriter() + return manager, nil } -// AllowedTopicPattern returns true if the given topic pattern is valid; this includes the wildcard character (*) -func AllowedTopicPattern(username string) bool { - return allowedTopicPatternRegex.MatchString(username) +// Authenticate checks username and password and returns a user if correct. The method +// returns in constant-ish time, regardless of whether the user exists or the password is +// correct or incorrect. +func (a *SQLiteManager) Authenticate(username, password string) (*User, error) { + if username == Everyone { + return nil, ErrUnauthenticated + } + user, err := a.User(username) + if err != nil { + bcrypt.CompareHashAndPassword([]byte(intentionalSlowDownHash), + []byte("intentional slow-down to avoid timing attacks")) + return nil, ErrUnauthenticated + } + if err := bcrypt.CompareHashAndPassword([]byte(user.Hash), []byte(password)); err != nil { + return nil, ErrUnauthenticated + } + return user, nil } -// Error constants used by the package -var ( - ErrUnauthenticated = errors.New("unauthenticated") - ErrUnauthorized = errors.New("unauthorized") - ErrInvalidArgument = errors.New("invalid argument") - ErrNotFound = errors.New("not found") -) +func (a *SQLiteManager) AuthenticateToken(token string) (*User, error) { + user, err := a.userByToken(token) + if err != nil { + return nil, ErrUnauthenticated + } + user.Token = token + return user, nil +} + +func (a *SQLiteManager) CreateToken(user *User) (*Token, error) { + token := util.RandomString(tokenLength) + expires := time.Now().Add(userTokenExpiryDuration) + if _, err := a.db.Exec(insertTokenQuery, user.Name, token, expires.Unix()); err != nil { + return nil, err + } + return &Token{ + Value: token, + Expires: expires.Unix(), + }, nil +} + +func (a *SQLiteManager) ExtendToken(user *User) (*Token, error) { + newExpires := time.Now().Add(userTokenExpiryDuration) + if _, err := a.db.Exec(updateTokenExpiryQuery, newExpires.Unix(), user.Name, user.Token); err != nil { + return nil, err + } + return &Token{ + Value: user.Token, + Expires: newExpires.Unix(), + }, nil +} + +func (a *SQLiteManager) RemoveToken(user *User) error { + if user.Token == "" { + return ErrUnauthorized + } + if _, err := a.db.Exec(deleteTokenQuery, user.Name, user.Token); err != nil { + return err + } + return nil +} + +func (a *SQLiteManager) RemoveExpiredTokens() error { + if _, err := a.db.Exec(deleteExpiredTokensQuery, time.Now().Unix()); err != nil { + return err + } + return nil +} + +func (a *SQLiteManager) ChangeSettings(user *User) error { + settings, err := json.Marshal(user.Prefs) + if err != nil { + return err + } + if _, err := a.db.Exec(updateUserSettingsQuery, string(settings), user.Name); err != nil { + return err + } + return nil +} + +func (a *SQLiteManager) EnqueueStats(user *User) { + a.mu.Lock() + defer a.mu.Unlock() + a.statsQueue[user.Name] = user +} + +func (a *SQLiteManager) userStatsQueueWriter() { + ticker := time.NewTicker(userStatsQueueWriterInterval) + for range ticker.C { + if err := a.writeUserStatsQueue(); err != nil { + log.Warn("UserManager: Writing user stats queue failed: %s", err.Error()) + } + } +} + +func (a *SQLiteManager) writeUserStatsQueue() error { + a.mu.Lock() + if len(a.statsQueue) == 0 { + a.mu.Unlock() + log.Trace("UserManager: No user stats updates to commit") + return nil + } + statsQueue := a.statsQueue + a.statsQueue = make(map[string]*User) + a.mu.Unlock() + tx, err := a.db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + log.Debug("UserManager: Writing user stats queue for %d user(s)", len(statsQueue)) + for username, u := range statsQueue { + log.Trace("UserManager: Updating stats for user %s: messages=%d, emails=%d", username, u.Stats.Messages, u.Stats.Emails) + if _, err := tx.Exec(updateUserStatsQuery, u.Stats.Messages, u.Stats.Emails, username); err != nil { + return err + } + } + return tx.Commit() +} + +// Authorize returns nil if the given user has access to the given topic using the desired +// permission. The user param may be nil to signal an anonymous user. +func (a *SQLiteManager) Authorize(user *User, topic string, perm Permission) error { + if user != nil && user.Role == RoleAdmin { + return nil // Admin can do everything + } + username := Everyone + if user != nil { + username = user.Name + } + // Select the read/write permissions for this user/topic combo. The query may return two + // rows (one for everyone, and one for the user), but prioritizes the user. The value for + // user.Name may be empty (= everyone). + rows, err := a.db.Query(selectTopicPermsQuery, username, topic) + if err != nil { + return err + } + defer rows.Close() + if !rows.Next() { + return a.resolvePerms(a.defaultRead, a.defaultWrite, perm) + } + var read, write bool + if err := rows.Scan(&read, &write); err != nil { + return err + } else if err := rows.Err(); err != nil { + return err + } + return a.resolvePerms(read, write, perm) +} + +func (a *SQLiteManager) resolvePerms(read, write bool, perm Permission) error { + if perm == PermissionRead && read { + return nil + } else if perm == PermissionWrite && write { + return nil + } + return ErrUnauthorized +} + +// AddUser adds a user with the given username, password and role. The password should be hashed +// before it is stored in a persistence layer. +func (a *SQLiteManager) AddUser(username, password string, role Role) error { + if !AllowedUsername(username) || !AllowedRole(role) { + return ErrInvalidArgument + } + hash, err := bcrypt.GenerateFromPassword([]byte(password), bcryptCost) + if err != nil { + return err + } + if _, err = a.db.Exec(insertUserQuery, username, hash, role); err != nil { + return err + } + return nil +} + +// 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 *SQLiteManager) RemoveUser(username string) error { + if !AllowedUsername(username) { + return ErrInvalidArgument + } + if _, err := a.db.Exec(deleteUserAccessQuery, username); err != nil { + return err + } + if _, err := a.db.Exec(deleteUserTokensQuery, username); err != nil { + return err + } + if _, err := a.db.Exec(deleteUserQuery, username); err != nil { + return err + } + return nil +} + +// Users returns a list of users. It always also returns the Everyone user ("*"). +func (a *SQLiteManager) Users() ([]*User, error) { + rows, err := a.db.Query(selectUsernamesQuery) + if err != nil { + return nil, err + } + defer rows.Close() + usernames := make([]string, 0) + for rows.Next() { + var username string + if err := rows.Scan(&username); err != nil { + return nil, err + } else if err := rows.Err(); err != nil { + return nil, err + } + usernames = append(usernames, username) + } + rows.Close() + users := make([]*User, 0) + for _, username := range usernames { + user, err := a.User(username) + if err != nil { + return nil, err + } + users = append(users, user) + } + everyone, err := a.everyoneUser() + if err != nil { + return nil, err + } + users = append(users, everyone) + return users, nil +} + +// User returns the user with the given username if it exists, or ErrNotFound otherwise. +// You may also pass Everyone to retrieve the anonymous user and its Grant list. +func (a *SQLiteManager) User(username string) (*User, error) { + if username == Everyone { + return a.everyoneUser() + } + rows, err := a.db.Query(selectUserByNameQuery, username) + if err != nil { + return nil, err + } + return a.readUser(rows) +} + +func (a *SQLiteManager) userByToken(token string) (*User, error) { + rows, err := a.db.Query(selectUserByTokenQuery, token) + if err != nil { + return nil, err + } + return a.readUser(rows) +} + +func (a *SQLiteManager) readUser(rows *sql.Rows) (*User, error) { + defer rows.Close() + var username, hash, role string + var settings, planCode sql.NullString + var messages, emails int64 + var messagesLimit, emailsLimit, attachmentFileSizeLimit, attachmentTotalSizeLimit sql.NullInt64 + if !rows.Next() { + return nil, ErrNotFound + } + if err := rows.Scan(&username, &hash, &role, &messages, &emails, &settings, &planCode, &messagesLimit, &emailsLimit, &attachmentFileSizeLimit, &attachmentTotalSizeLimit); err != nil { + return nil, err + } else if err := rows.Err(); err != nil { + return nil, err + } + grants, err := a.readGrants(username) + if err != nil { + return nil, err + } + user := &User{ + Name: username, + Hash: hash, + Role: Role(role), + Grants: grants, + Stats: &Stats{ + Messages: messages, + Emails: emails, + }, + } + if settings.Valid { + user.Prefs = &Prefs{} + if err := json.Unmarshal([]byte(settings.String), user.Prefs); err != nil { + return nil, err + } + } + if planCode.Valid { + user.Plan = &Plan{ + Code: planCode.String, + Upgradable: true, // FIXME + MessagesLimit: messagesLimit.Int64, + EmailsLimit: emailsLimit.Int64, + AttachmentFileSizeLimit: attachmentFileSizeLimit.Int64, + AttachmentTotalSizeLimit: attachmentTotalSizeLimit.Int64, + } + } + return user, nil +} + +func (a *SQLiteManager) everyoneUser() (*User, error) { + grants, err := a.readGrants(Everyone) + if err != nil { + return nil, err + } + return &User{ + Name: Everyone, + Hash: "", + Role: RoleAnonymous, + Grants: grants, + }, nil +} + +func (a *SQLiteManager) readGrants(username string) ([]Grant, error) { + rows, err := a.db.Query(selectUserAccessQuery, username) + if err != nil { + return nil, err + } + defer rows.Close() + grants := make([]Grant, 0) + for rows.Next() { + var topic string + var read, write bool + if err := rows.Scan(&topic, &read, &write); err != nil { + return nil, err + } else if err := rows.Err(); err != nil { + return nil, err + } + grants = append(grants, Grant{ + TopicPattern: fromSQLWildcard(topic), + AllowRead: read, + AllowWrite: write, + }) + } + return grants, nil +} + +// ChangePassword changes a user's password +func (a *SQLiteManager) ChangePassword(username, password string) error { + hash, err := bcrypt.GenerateFromPassword([]byte(password), bcryptCost) + if err != nil { + return err + } + if _, err := a.db.Exec(updateUserPassQuery, hash, username); err != nil { + return err + } + return nil +} + +// 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 *SQLiteManager) ChangeRole(username string, role Role) error { + if !AllowedUsername(username) || !AllowedRole(role) { + return ErrInvalidArgument + } + if _, err := a.db.Exec(updateUserRoleQuery, string(role), username); err != nil { + return err + } + if role == RoleAdmin { + if _, err := a.db.Exec(deleteUserAccessQuery, username); err != nil { + return err + } + } + return nil +} + +// AllowAccess adds or updates an entry in th access control list for a specific user. It controls +// read/write access to a topic. The parameter topicPattern may include wildcards (*). +func (a *SQLiteManager) AllowAccess(username string, topicPattern string, read bool, write bool) error { + if (!AllowedUsername(username) && username != Everyone) || !AllowedTopicPattern(topicPattern) { + return ErrInvalidArgument + } + if _, err := a.db.Exec(upsertUserAccessQuery, username, toSQLWildcard(topicPattern), read, write); err != nil { + return err + } + return nil +} + +// 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 *SQLiteManager) ResetAccess(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) + return err + } else if topicPattern == "" { + _, err := a.db.Exec(deleteUserAccessQuery, username) + return err + } + _, err := a.db.Exec(deleteTopicAccessQuery, username, toSQLWildcard(topicPattern)) + return err +} + +// DefaultAccess returns the default read/write access if no access control entry matches +func (a *SQLiteManager) DefaultAccess() (read bool, write bool) { + return a.defaultRead, a.defaultWrite +} + +func toSQLWildcard(s string) string { + return strings.ReplaceAll(s, "*", "%") +} + +func fromSQLWildcard(s string) string { + return strings.ReplaceAll(s, "%", "*") +} + +func setupAuthDB(db *sql.DB) error { + // If 'schemaVersion' table does not exist, this must be a new database + rowsSV, err := db.Query(selectSchemaVersionQuery) + if err != nil { + return setupNewAuthDB(db) + } + defer rowsSV.Close() + + // If 'schemaVersion' table exists, read version and potentially upgrade + schemaVersion := 0 + if !rowsSV.Next() { + return errors.New("cannot determine schema version: database file may be corrupt") + } + if err := rowsSV.Scan(&schemaVersion); err != nil { + return err + } + rowsSV.Close() + + // Do migrations + if schemaVersion == currentSchemaVersion { + return nil + } + return fmt.Errorf("unexpected schema version found: %d", schemaVersion) +} + +func setupNewAuthDB(db *sql.DB) error { + if _, err := db.Exec(createAuthTablesQueries); err != nil { + return err + } + if _, err := db.Exec(insertSchemaVersion, currentSchemaVersion); err != nil { + return err + } + return nil +} diff --git a/user/manager_sqlite.go b/user/manager_sqlite.go deleted file mode 100644 index 70b3dc8..0000000 --- a/user/manager_sqlite.go +++ /dev/null @@ -1,592 +0,0 @@ -package user - -import ( - "database/sql" - "encoding/json" - "errors" - "fmt" - _ "github.com/mattn/go-sqlite3" // SQLite driver - "golang.org/x/crypto/bcrypt" - "heckel.io/ntfy/log" - "heckel.io/ntfy/util" - "strings" - "sync" - "time" -) - -const ( - tokenLength = 32 - bcryptCost = 10 - intentionalSlowDownHash = "$2a$10$YFCQvqQDwIIwnJM1xkAYOeih0dg17UVGanaTStnrSzC8NCWxcLDwy" // Cost should match bcryptCost - userStatsQueueWriterInterval = 33 * time.Second - userTokenExpiryDuration = 72 * time.Hour -) - -// Manager-related queries -const ( - createAuthTablesQueries = ` - BEGIN; - CREATE TABLE IF NOT EXISTS plan ( - id INT NOT NULL, - code TEXT NOT NULL, - messages_limit INT NOT NULL, - emails_limit INT NOT NULL, - attachment_file_size_limit INT NOT NULL, - attachment_total_size_limit INT NOT NULL, - PRIMARY KEY (id) - ); - CREATE TABLE IF NOT EXISTS user ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - plan_id INT, - user TEXT NOT NULL, - pass TEXT NOT NULL, - role TEXT NOT NULL, - messages INT NOT NULL DEFAULT (0), - emails INT NOT NULL DEFAULT (0), - settings JSON, - FOREIGN KEY (plan_id) REFERENCES plan (id) - ); - CREATE UNIQUE INDEX idx_user ON user (user); - CREATE TABLE IF NOT EXISTS user_access ( - user_id INT NOT NULL, - topic TEXT NOT NULL, - read INT NOT NULL, - write INT NOT NULL, - PRIMARY KEY (user_id, topic), - FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE - ); - CREATE TABLE IF NOT EXISTS user_token ( - user_id INT NOT NULL, - token TEXT NOT NULL, - expires INT NOT NULL, - PRIMARY KEY (user_id, token), - FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE - ); - CREATE TABLE IF NOT EXISTS schemaVersion ( - id INT PRIMARY KEY, - version INT NOT NULL - ); - INSERT INTO user (id, user, pass, role) VALUES (1, '*', '', 'anonymous') ON CONFLICT (id) DO NOTHING; - COMMIT; - ` - selectUserByNameQuery = ` - SELECT u.user, u.pass, u.role, u.messages, u.emails, u.settings, p.code, p.messages_limit, p.emails_limit, p.attachment_file_size_limit, p.attachment_total_size_limit - FROM user u - LEFT JOIN plan p on p.id = u.plan_id - WHERE user = ? - ` - selectUserByTokenQuery = ` - SELECT u.user, u.pass, u.role, u.messages, u.emails, u.settings, p.code, p.messages_limit, p.emails_limit, p.attachment_file_size_limit, p.attachment_total_size_limit - FROM user u - JOIN user_token t on u.id = t.user_id - LEFT JOIN plan p on p.id = u.plan_id - WHERE t.token = ? - ` - selectTopicPermsQuery = ` - SELECT read, write - FROM user_access - JOIN user ON user.user = '*' OR user.user = ? - WHERE ? LIKE user_access.topic - ORDER BY user.user DESC - ` -) - -// Manager-related queries -const ( - insertUserQuery = `INSERT INTO user (user, pass, role) VALUES (?, ?, ?)` - selectUsernamesQuery = `SELECT user FROM user ORDER BY role, user` - updateUserPassQuery = `UPDATE user SET pass = ? WHERE user = ?` - updateUserRoleQuery = `UPDATE user SET role = ? WHERE user = ?` - updateUserSettingsQuery = `UPDATE user SET settings = ? WHERE user = ?` - updateUserStatsQuery = `UPDATE user SET messages = ?, emails = ? WHERE user = ?` - deleteUserQuery = `DELETE FROM user WHERE user = ?` - - upsertUserAccessQuery = `INSERT INTO user_access (user_id, topic, read, write) VALUES ((SELECT id FROM user WHERE user = ?), ?, ?, ?)` - selectUserAccessQuery = `SELECT topic, read, write FROM user_access WHERE user_id = (SELECT id FROM user WHERE user = ?)` - deleteAllAccessQuery = `DELETE FROM user_access` - deleteUserAccessQuery = `DELETE FROM user_access WHERE user_id = (SELECT id FROM user WHERE user = ?)` - deleteTopicAccessQuery = `DELETE FROM user_access WHERE user_id = (SELECT id FROM user WHERE user = ?) AND topic = ?` - - insertTokenQuery = `INSERT INTO user_token (user_id, token, expires) VALUES ((SELECT id FROM user WHERE user = ?), ?, ?)` - updateTokenExpiryQuery = `UPDATE user_token SET expires = ? WHERE user_id = (SELECT id FROM user WHERE user = ?) AND token = ?` - deleteTokenQuery = `DELETE FROM user_token WHERE user_id = (SELECT id FROM user WHERE user = ?) AND token = ?` - deleteExpiredTokensQuery = `DELETE FROM user_token WHERE expires < ?` - deleteUserTokensQuery = `DELETE FROM user_token WHERE user_id = (SELECT id FROM user WHERE user = ?)` -) - -// Schema management queries -const ( - currentSchemaVersion = 1 - insertSchemaVersion = `INSERT INTO schemaVersion VALUES (1, ?)` - selectSchemaVersionQuery = `SELECT version FROM schemaVersion WHERE id = 1` -) - -// SQLiteManager is an implementation of Manager. It stores users and access control list -// in a SQLite database. -type SQLiteManager struct { - db *sql.DB - defaultRead bool - defaultWrite bool - statsQueue map[string]*User // Username -> User, for "unimportant" user updates - mu sync.Mutex -} - -var _ Manager = (*SQLiteManager)(nil) - -// NewSQLiteAuthManager creates a new SQLiteManager instance -func NewSQLiteAuthManager(filename string, defaultRead, defaultWrite bool) (*SQLiteManager, error) { - db, err := sql.Open("sqlite3", filename) - if err != nil { - return nil, err - } - if err := setupAuthDB(db); err != nil { - return nil, err - } - manager := &SQLiteManager{ - db: db, - defaultRead: defaultRead, - defaultWrite: defaultWrite, - statsQueue: make(map[string]*User), - } - go manager.userStatsQueueWriter() - return manager, nil -} - -// Authenticate checks username and password and returns a user if correct. The method -// returns in constant-ish time, regardless of whether the user exists or the password is -// correct or incorrect. -func (a *SQLiteManager) Authenticate(username, password string) (*User, error) { - if username == Everyone { - return nil, ErrUnauthenticated - } - user, err := a.User(username) - if err != nil { - bcrypt.CompareHashAndPassword([]byte(intentionalSlowDownHash), - []byte("intentional slow-down to avoid timing attacks")) - return nil, ErrUnauthenticated - } - if err := bcrypt.CompareHashAndPassword([]byte(user.Hash), []byte(password)); err != nil { - return nil, ErrUnauthenticated - } - return user, nil -} - -func (a *SQLiteManager) AuthenticateToken(token string) (*User, error) { - user, err := a.userByToken(token) - if err != nil { - return nil, ErrUnauthenticated - } - user.Token = token - return user, nil -} - -func (a *SQLiteManager) CreateToken(user *User) (*Token, error) { - token := util.RandomString(tokenLength) - expires := time.Now().Add(userTokenExpiryDuration) - if _, err := a.db.Exec(insertTokenQuery, user.Name, token, expires.Unix()); err != nil { - return nil, err - } - return &Token{ - Value: token, - Expires: expires.Unix(), - }, nil -} - -func (a *SQLiteManager) ExtendToken(user *User) (*Token, error) { - newExpires := time.Now().Add(userTokenExpiryDuration) - if _, err := a.db.Exec(updateTokenExpiryQuery, newExpires.Unix(), user.Name, user.Token); err != nil { - return nil, err - } - return &Token{ - Value: user.Token, - Expires: newExpires.Unix(), - }, nil -} - -func (a *SQLiteManager) RemoveToken(user *User) error { - if user.Token == "" { - return ErrUnauthorized - } - if _, err := a.db.Exec(deleteTokenQuery, user.Name, user.Token); err != nil { - return err - } - return nil -} - -func (a *SQLiteManager) RemoveExpiredTokens() error { - if _, err := a.db.Exec(deleteExpiredTokensQuery, time.Now().Unix()); err != nil { - return err - } - return nil -} - -func (a *SQLiteManager) ChangeSettings(user *User) error { - settings, err := json.Marshal(user.Prefs) - if err != nil { - return err - } - if _, err := a.db.Exec(updateUserSettingsQuery, string(settings), user.Name); err != nil { - return err - } - return nil -} - -func (a *SQLiteManager) EnqueueStats(user *User) { - a.mu.Lock() - defer a.mu.Unlock() - a.statsQueue[user.Name] = user -} - -func (a *SQLiteManager) userStatsQueueWriter() { - ticker := time.NewTicker(userStatsQueueWriterInterval) - for range ticker.C { - if err := a.writeUserStatsQueue(); err != nil { - log.Warn("UserManager: Writing user stats queue failed: %s", err.Error()) - } - } -} - -func (a *SQLiteManager) writeUserStatsQueue() error { - a.mu.Lock() - if len(a.statsQueue) == 0 { - a.mu.Unlock() - log.Trace("UserManager: No user stats updates to commit") - return nil - } - statsQueue := a.statsQueue - a.statsQueue = make(map[string]*User) - a.mu.Unlock() - tx, err := a.db.Begin() - if err != nil { - return err - } - defer tx.Rollback() - log.Debug("UserManager: Writing user stats queue for %d user(s)", len(statsQueue)) - for username, u := range statsQueue { - log.Trace("UserManager: Updating stats for user %s: messages=%d, emails=%d", username, u.Stats.Messages, u.Stats.Emails) - if _, err := tx.Exec(updateUserStatsQuery, u.Stats.Messages, u.Stats.Emails, username); err != nil { - return err - } - } - return tx.Commit() -} - -// Authorize returns nil if the given user has access to the given topic using the desired -// permission. The user param may be nil to signal an anonymous user. -func (a *SQLiteManager) Authorize(user *User, topic string, perm Permission) error { - if user != nil && user.Role == RoleAdmin { - return nil // Admin can do everything - } - username := Everyone - if user != nil { - username = user.Name - } - // Select the read/write permissions for this user/topic combo. The query may return two - // rows (one for everyone, and one for the user), but prioritizes the user. The value for - // user.Name may be empty (= everyone). - rows, err := a.db.Query(selectTopicPermsQuery, username, topic) - if err != nil { - return err - } - defer rows.Close() - if !rows.Next() { - return a.resolvePerms(a.defaultRead, a.defaultWrite, perm) - } - var read, write bool - if err := rows.Scan(&read, &write); err != nil { - return err - } else if err := rows.Err(); err != nil { - return err - } - return a.resolvePerms(read, write, perm) -} - -func (a *SQLiteManager) resolvePerms(read, write bool, perm Permission) error { - if perm == PermissionRead && read { - return nil - } else if perm == PermissionWrite && write { - return nil - } - return ErrUnauthorized -} - -// AddUser adds a user with the given username, password and role. The password should be hashed -// before it is stored in a persistence layer. -func (a *SQLiteManager) AddUser(username, password string, role Role) error { - if !AllowedUsername(username) || !AllowedRole(role) { - return ErrInvalidArgument - } - hash, err := bcrypt.GenerateFromPassword([]byte(password), bcryptCost) - if err != nil { - return err - } - if _, err = a.db.Exec(insertUserQuery, username, hash, role); err != nil { - return err - } - return nil -} - -// 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 *SQLiteManager) RemoveUser(username string) error { - if !AllowedUsername(username) { - return ErrInvalidArgument - } - if _, err := a.db.Exec(deleteUserAccessQuery, username); err != nil { - return err - } - if _, err := a.db.Exec(deleteUserTokensQuery, username); err != nil { - return err - } - if _, err := a.db.Exec(deleteUserQuery, username); err != nil { - return err - } - return nil -} - -// Users returns a list of users. It always also returns the Everyone user ("*"). -func (a *SQLiteManager) Users() ([]*User, error) { - rows, err := a.db.Query(selectUsernamesQuery) - if err != nil { - return nil, err - } - defer rows.Close() - usernames := make([]string, 0) - for rows.Next() { - var username string - if err := rows.Scan(&username); err != nil { - return nil, err - } else if err := rows.Err(); err != nil { - return nil, err - } - usernames = append(usernames, username) - } - rows.Close() - users := make([]*User, 0) - for _, username := range usernames { - user, err := a.User(username) - if err != nil { - return nil, err - } - users = append(users, user) - } - everyone, err := a.everyoneUser() - if err != nil { - return nil, err - } - users = append(users, everyone) - return users, nil -} - -// User returns the user with the given username if it exists, or ErrNotFound otherwise. -// You may also pass Everyone to retrieve the anonymous user and its Grant list. -func (a *SQLiteManager) User(username string) (*User, error) { - if username == Everyone { - return a.everyoneUser() - } - rows, err := a.db.Query(selectUserByNameQuery, username) - if err != nil { - return nil, err - } - return a.readUser(rows) -} - -func (a *SQLiteManager) userByToken(token string) (*User, error) { - rows, err := a.db.Query(selectUserByTokenQuery, token) - if err != nil { - return nil, err - } - return a.readUser(rows) -} - -func (a *SQLiteManager) readUser(rows *sql.Rows) (*User, error) { - defer rows.Close() - var username, hash, role string - var settings, planCode sql.NullString - var messages, emails int64 - var messagesLimit, emailsLimit, attachmentFileSizeLimit, attachmentTotalSizeLimit sql.NullInt64 - if !rows.Next() { - return nil, ErrNotFound - } - if err := rows.Scan(&username, &hash, &role, &messages, &emails, &settings, &planCode, &messagesLimit, &emailsLimit, &attachmentFileSizeLimit, &attachmentTotalSizeLimit); err != nil { - return nil, err - } else if err := rows.Err(); err != nil { - return nil, err - } - grants, err := a.readGrants(username) - if err != nil { - return nil, err - } - user := &User{ - Name: username, - Hash: hash, - Role: Role(role), - Grants: grants, - Stats: &Stats{ - Messages: messages, - Emails: emails, - }, - } - if settings.Valid { - user.Prefs = &Prefs{} - if err := json.Unmarshal([]byte(settings.String), user.Prefs); err != nil { - return nil, err - } - } - if planCode.Valid { - user.Plan = &Plan{ - Code: planCode.String, - Upgradable: true, // FIXME - MessagesLimit: messagesLimit.Int64, - EmailsLimit: emailsLimit.Int64, - AttachmentFileSizeLimit: attachmentFileSizeLimit.Int64, - AttachmentTotalSizeLimit: attachmentTotalSizeLimit.Int64, - } - } - return user, nil -} - -func (a *SQLiteManager) everyoneUser() (*User, error) { - grants, err := a.readGrants(Everyone) - if err != nil { - return nil, err - } - return &User{ - Name: Everyone, - Hash: "", - Role: RoleAnonymous, - Grants: grants, - }, nil -} - -func (a *SQLiteManager) readGrants(username string) ([]Grant, error) { - rows, err := a.db.Query(selectUserAccessQuery, username) - if err != nil { - return nil, err - } - defer rows.Close() - grants := make([]Grant, 0) - for rows.Next() { - var topic string - var read, write bool - if err := rows.Scan(&topic, &read, &write); err != nil { - return nil, err - } else if err := rows.Err(); err != nil { - return nil, err - } - grants = append(grants, Grant{ - TopicPattern: fromSQLWildcard(topic), - AllowRead: read, - AllowWrite: write, - }) - } - return grants, nil -} - -// ChangePassword changes a user's password -func (a *SQLiteManager) ChangePassword(username, password string) error { - hash, err := bcrypt.GenerateFromPassword([]byte(password), bcryptCost) - if err != nil { - return err - } - if _, err := a.db.Exec(updateUserPassQuery, hash, username); err != nil { - return err - } - return nil -} - -// 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 *SQLiteManager) ChangeRole(username string, role Role) error { - if !AllowedUsername(username) || !AllowedRole(role) { - return ErrInvalidArgument - } - if _, err := a.db.Exec(updateUserRoleQuery, string(role), username); err != nil { - return err - } - if role == RoleAdmin { - if _, err := a.db.Exec(deleteUserAccessQuery, username); err != nil { - return err - } - } - return nil -} - -// AllowAccess adds or updates an entry in th access control list for a specific user. It controls -// read/write access to a topic. The parameter topicPattern may include wildcards (*). -func (a *SQLiteManager) AllowAccess(username string, topicPattern string, read bool, write bool) error { - if (!AllowedUsername(username) && username != Everyone) || !AllowedTopicPattern(topicPattern) { - return ErrInvalidArgument - } - if _, err := a.db.Exec(upsertUserAccessQuery, username, toSQLWildcard(topicPattern), read, write); err != nil { - return err - } - return nil -} - -// 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 *SQLiteManager) ResetAccess(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) - return err - } else if topicPattern == "" { - _, err := a.db.Exec(deleteUserAccessQuery, username) - return err - } - _, err := a.db.Exec(deleteTopicAccessQuery, username, toSQLWildcard(topicPattern)) - return err -} - -// DefaultAccess returns the default read/write access if no access control entry matches -func (a *SQLiteManager) DefaultAccess() (read bool, write bool) { - return a.defaultRead, a.defaultWrite -} - -func toSQLWildcard(s string) string { - return strings.ReplaceAll(s, "*", "%") -} - -func fromSQLWildcard(s string) string { - return strings.ReplaceAll(s, "%", "*") -} - -func setupAuthDB(db *sql.DB) error { - // If 'schemaVersion' table does not exist, this must be a new database - rowsSV, err := db.Query(selectSchemaVersionQuery) - if err != nil { - return setupNewAuthDB(db) - } - defer rowsSV.Close() - - // If 'schemaVersion' table exists, read version and potentially upgrade - schemaVersion := 0 - if !rowsSV.Next() { - return errors.New("cannot determine schema version: database file may be corrupt") - } - if err := rowsSV.Scan(&schemaVersion); err != nil { - return err - } - rowsSV.Close() - - // Do migrations - if schemaVersion == currentSchemaVersion { - return nil - } - return fmt.Errorf("unexpected schema version found: %d", schemaVersion) -} - -func setupNewAuthDB(db *sql.DB) error { - if _, err := db.Exec(createAuthTablesQueries); err != nil { - return err - } - if _, err := db.Exec(insertSchemaVersion, currentSchemaVersion); err != nil { - return err - } - return nil -} diff --git a/user/manager_sqlite_test.go b/user/manager_test.go similarity index 100% rename from user/manager_sqlite_test.go rename to user/manager_test.go diff --git a/user/types.go b/user/types.go new file mode 100644 index 0000000..94febde --- /dev/null +++ b/user/types.go @@ -0,0 +1,177 @@ +// Package user deals with authentication and authorization against topics +package user + +import ( + "errors" + "regexp" +) + +// Manager is a generic interface to implement password and token based authentication and authorization +type Manager interface { + // Authenticate checks username and password and returns a user if correct. The method + // returns in constant-ish time, regardless of whether the user exists or the password is + // correct or incorrect. + Authenticate(username, password string) (*User, error) + + AuthenticateToken(token string) (*User, error) + CreateToken(user *User) (*Token, error) + ExtendToken(user *User) (*Token, error) + RemoveToken(user *User) error + RemoveExpiredTokens() error + ChangeSettings(user *User) error + EnqueueStats(user *User) + + // Authorize returns nil if the given user has access to the given topic using the desired + // permission. The user param may be nil to signal an anonymous user. + Authorize(user *User, topic string, perm Permission) error + + // AddUser adds a user with the given username, password and role. The password should be hashed + // before it is stored in a persistence layer. + AddUser(username, password string, role Role) error + + // 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. + RemoveUser(username string) error + + // Users returns a list of users. It always also returns the Everyone user ("*"). + Users() ([]*User, error) + + // User returns the user with the given username if it exists, or ErrNotFound otherwise. + // You may also pass Everyone to retrieve the anonymous user and its Grant list. + User(username string) (*User, error) + + // ChangePassword changes a user's password + ChangePassword(username, password string) 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. + ChangeRole(username string, role Role) error + + // AllowAccess adds or updates an entry in th access control list for a specific user. It controls + // read/write access to a topic. The parameter topicPattern may include wildcards (*). + AllowAccess(username string, topicPattern string, read bool, write bool) error + + // 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 (*). + ResetAccess(username string, topicPattern string) error + + // DefaultAccess returns the default read/write access if no access control entry matches + DefaultAccess() (read bool, write bool) +} + +// User is a struct that represents a user +type User struct { + Name string + Hash string // password hash (bcrypt) + Token string // Only set if token was used to log in + Role Role + Grants []Grant + Prefs *Prefs + Plan *Plan + Stats *Stats +} + +type Token struct { + Value string + Expires int64 +} + +type Prefs struct { + Language string `json:"language,omitempty"` + Notification *NotificationPrefs `json:"notification,omitempty"` + Subscriptions []*Subscription `json:"subscriptions,omitempty"` +} + +type PlanCode string + +const ( + PlanUnlimited = PlanCode("unlimited") + PlanDefault = PlanCode("default") + PlanNone = PlanCode("none") +) + +type Plan struct { + Code string `json:"name"` + Upgradable bool `json:"upgradable"` + MessagesLimit int64 `json:"messages_limit"` + EmailsLimit int64 `json:"emails_limit"` + AttachmentFileSizeLimit int64 `json:"attachment_file_size_limit"` + AttachmentTotalSizeLimit int64 `json:"attachment_total_size_limit"` +} + +type Subscription struct { + ID string `json:"id"` + BaseURL string `json:"base_url"` + Topic string `json:"topic"` + DisplayName string `json:"display_name"` +} + +type NotificationPrefs struct { + Sound string `json:"sound,omitempty"` + MinPriority int `json:"min_priority,omitempty"` + DeleteAfter int `json:"delete_after,omitempty"` +} + +type Stats struct { + Messages int64 + Emails int64 +} + +// Grant is a struct that represents an access control entry to a topic +type Grant struct { + TopicPattern string // May include wildcard (*) + AllowRead bool + AllowWrite bool +} + +// Permission represents a read or write permission to a topic +type Permission int + +// Permissions to a topic +const ( + PermissionRead = Permission(1) + PermissionWrite = Permission(2) +) + +// Role represents a user's role, either admin or regular user +type Role string + +// User roles +const ( + RoleAdmin = Role("admin") + RoleUser = Role("user") + RoleAnonymous = Role("anonymous") +) + +// Everyone is a special username representing anonymous users +const ( + Everyone = "*" +) + +var ( + allowedUsernameRegex = regexp.MustCompile(`^[-_.@a-zA-Z0-9]+$`) // Does not include Everyone (*) + allowedTopicPatternRegex = regexp.MustCompile(`^[-_*A-Za-z0-9]{1,64}$`) // Adds '*' for wildcards! +) + +// AllowedRole returns true if the given role can be used for new users +func AllowedRole(role Role) bool { + return role == RoleUser || role == RoleAdmin +} + +// AllowedUsername returns true if the given username is valid +func AllowedUsername(username string) bool { + return allowedUsernameRegex.MatchString(username) +} + +// AllowedTopicPattern returns true if the given topic pattern is valid; this includes the wildcard character (*) +func AllowedTopicPattern(username string) bool { + return allowedTopicPatternRegex.MatchString(username) +} + +// Error constants used by the package +var ( + ErrUnauthenticated = errors.New("unauthenticated") + ErrUnauthorized = errors.New("unauthorized") + ErrInvalidArgument = errors.New("invalid argument") + ErrNotFound = errors.New("not found") +) diff --git a/web/src/app/AccountApi.js b/web/src/app/AccountApi.js index 0b429ac..d156589 100644 --- a/web/src/app/AccountApi.js +++ b/web/src/app/AccountApi.js @@ -175,6 +175,25 @@ class AccountApi { return subscription; } + async updateSubscription(remoteId, payload) { + const url = accountSubscriptionSingleUrl(config.baseUrl, remoteId); + const body = JSON.stringify(payload); + console.log(`[AccountApi] Updating user subscription ${url}: ${body}`); + const response = await fetch(url, { + method: "PATCH", + headers: maybeWithBearerAuth({}, session.token()), + body: body + }); + if (response.status === 401 || response.status === 403) { + throw new UnauthorizedError(); + } else if (response.status !== 200) { + throw new Error(`Unexpected server response ${response.status}`); + } + const subscription = await response.json(); + console.log(`[AccountApi] Subscription`, subscription); + return subscription; + } + async deleteSubscription(remoteId) { const url = accountSubscriptionSingleUrl(config.baseUrl, remoteId); console.log(`[AccountApi] Removing user subscription ${url}`); diff --git a/web/src/app/Session.js b/web/src/app/Session.js index 06b5f8f..c14a21b 100644 --- a/web/src/app/Session.js +++ b/web/src/app/Session.js @@ -1,3 +1,5 @@ +import routes from "../components/routes"; + class Session { store(username, token) { localStorage.setItem("user", username); @@ -9,6 +11,11 @@ class Session { localStorage.removeItem("token"); } + resetAndRedirect(url) { + this.reset(); + window.location.href = url; + } + exists() { return this.username() && this.token(); } diff --git a/web/src/app/SubscriptionManager.js b/web/src/app/SubscriptionManager.js index f793ddf..c87159d 100644 --- a/web/src/app/SubscriptionManager.js +++ b/web/src/app/SubscriptionManager.js @@ -36,12 +36,15 @@ class SubscriptionManager { } async syncFromRemote(remoteSubscriptions) { + console.log(`[SubscriptionManager] Syncing subscriptions from remote`, remoteSubscriptions); + // Add remote subscriptions let remoteIds = []; for (let i = 0; i < remoteSubscriptions.length; i++) { const remote = remoteSubscriptions[i]; const local = await this.add(remote.base_url, remote.topic); await this.setRemoteId(local.id, remote.id); + await this.setDisplayName(local.id, remote.display_name); remoteIds.push(remote.id); } @@ -49,7 +52,7 @@ class SubscriptionManager { const localSubscriptions = await db.subscriptions.toArray(); for (let i = 0; i < localSubscriptions.length; i++) { const local = localSubscriptions[i]; - if (local.remoteId && !remoteIds.includes(local.remoteId)) { + if (!local.remoteId || !remoteIds.includes(local.remoteId)) { await this.remove(local.id); } } diff --git a/web/src/components/ActionBar.js b/web/src/components/ActionBar.js index 2a079be..70ee7e6 100644 --- a/web/src/components/ActionBar.js +++ b/web/src/components/ActionBar.js @@ -204,7 +204,7 @@ const SettingsIcons = (props) => { return ( <> - + {subscription.mutedUntil ? : } diff --git a/web/src/components/Preferences.js b/web/src/components/Preferences.js index 0247ff1..29e2989 100644 --- a/web/src/components/Preferences.js +++ b/web/src/components/Preferences.js @@ -319,42 +319,52 @@ const UserTable = (props) => { } }; return ( - - - - {t("prefs_users_table_user_header")} - {t("prefs_users_table_base_url_header")} - - - - - {props.users?.map(user => ( - - {user.username} - {user.baseUrl} - - handleEditClick(user)} aria-label={t("prefs_users_edit_button")}> - - - handleDeleteClick(user)} aria-label={t("prefs_users_delete_button")}> - - - +
+
+ + + {t("prefs_users_table_user_header")} + {t("prefs_users_table_base_url_header")} + - ))} - - -
+ + + {props.users?.map(user => ( + + {user.username} + {user.baseUrl} + + handleEditClick(user)} + aria-label={t("prefs_users_edit_button")}> + + + handleDeleteClick(user)} + aria-label={t("prefs_users_delete_button")}> + + + + + ))} + + + + {session.exists() && + + xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx + + } + ); }; @@ -672,8 +682,7 @@ const maybeUpdateAccountSettings = async (payload) => { } catch (e) { console.log(`[Preferences] Error updating account settings`, e); if ((e instanceof UnauthorizedError)) { - session.reset(); - window.location.href = routes.login; + session.resetAndRedirect(routes.login); } } }; diff --git a/web/src/components/SubscriptionSettingsDialog.js b/web/src/components/SubscriptionSettingsDialog.js index a31fe0f..20f6f1e 100644 --- a/web/src/components/SubscriptionSettingsDialog.js +++ b/web/src/components/SubscriptionSettingsDialog.js @@ -15,6 +15,9 @@ import subscriptionManager from "../app/SubscriptionManager"; import poller from "../app/Poller"; import DialogFooter from "./DialogFooter"; import {useTranslation} from "react-i18next"; +import accountApi, {UnauthorizedError} from "../app/AccountApi"; +import session from "../app/Session"; +import routes from "./routes"; const SubscriptionSettingsDialog = (props) => { const { t } = useTranslation(); @@ -23,6 +26,17 @@ const SubscriptionSettingsDialog = (props) => { const fullScreen = useMediaQuery(theme.breakpoints.down('sm')); const handleSave = async () => { await subscriptionManager.setDisplayName(subscription.id, displayName); + if (session.exists() && subscription.remoteId) { + try { + console.log(`[SubscriptionSettingsDialog] Updating subscription display name to ${displayName}`); + await accountApi.updateSubscription(subscription.remoteId, { display_name: displayName }); + } catch (e) { + console.log(`[SubscriptionSettingsDialog] Error updating subscription`, e); + if ((e instanceof UnauthorizedError)) { + session.resetAndRedirect(routes.login); + } + } + } props.onClose(); } return (