1
0
Fork 0
mirror of https://github.com/binwiederhier/ntfy.git synced 2024-11-22 19:33:27 +01:00

Display name sync

This commit is contained in:
binwiederhier 2022-12-25 22:29:55 -05:00
parent 7ae8049438
commit 2fb4bd4975
12 changed files with 897 additions and 793 deletions

View file

@ -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 {

View file

@ -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
}

View file

@ -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
}

View file

@ -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
}

177
user/types.go Normal file
View file

@ -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")
)

View file

@ -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}`);

View file

@ -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();
}

View file

@ -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);
}
}

View file

@ -204,7 +204,7 @@ const SettingsIcons = (props) => {
return (
<>
<IconButton color="inherit" size="large" edge="end" onClick={handleToggleMute} sx={{marginRight: 0}} aria-label={t("action_bar_toggle_mute")}>
<IconButton color="inherit" size="large" edge="end" onClick={handleToggleMute} aria-label={t("action_bar_toggle_mute")}>
{subscription.mutedUntil ? <NotificationsOffIcon/> : <NotificationsIcon/>}
</IconButton>
<IconButton color="inherit" size="large" edge="end" ref={anchorRef} onClick={handleToggleOpen} aria-label={t("action_bar_toggle_action_menu")}>

View file

@ -319,42 +319,52 @@ const UserTable = (props) => {
}
};
return (
<Table size="small" aria-label={t("prefs_users_table")}>
<TableHead>
<TableRow>
<TableCell sx={{paddingLeft: 0}}>{t("prefs_users_table_user_header")}</TableCell>
<TableCell>{t("prefs_users_table_base_url_header")}</TableCell>
<TableCell/>
</TableRow>
</TableHead>
<TableBody>
{props.users?.map(user => (
<TableRow
key={user.baseUrl}
sx={{ '&:last-child td, &:last-child th': { border: 0 } }}
>
<TableCell component="th" scope="row" sx={{paddingLeft: 0}} aria-label={t("prefs_users_table_user_header")}>{user.username}</TableCell>
<TableCell aria-label={t("prefs_users_table_base_url_header")}>{user.baseUrl}</TableCell>
<TableCell align="right">
<IconButton onClick={() => handleEditClick(user)} aria-label={t("prefs_users_edit_button")}>
<EditIcon/>
</IconButton>
<IconButton onClick={() => handleDeleteClick(user)} aria-label={t("prefs_users_delete_button")}>
<CloseIcon />
</IconButton>
</TableCell>
<div>
<Table size="small" aria-label={t("prefs_users_table")}>
<TableHead>
<TableRow>
<TableCell sx={{paddingLeft: 0}}>{t("prefs_users_table_user_header")}</TableCell>
<TableCell>{t("prefs_users_table_base_url_header")}</TableCell>
<TableCell/>
</TableRow>
))}
</TableBody>
<UserDialog
key={`userEditDialog${dialogKey}`}
open={dialogOpen}
user={dialogUser}
users={props.users}
onCancel={handleDialogCancel}
onSubmit={handleDialogSubmit}
/>
</Table>
</TableHead>
<TableBody>
{props.users?.map(user => (
<TableRow
key={user.baseUrl}
sx={{'&:last-child td, &:last-child th': {border: 0}}}
>
<TableCell component="th" scope="row" sx={{paddingLeft: 0}}
aria-label={t("prefs_users_table_user_header")}>{user.username}</TableCell>
<TableCell aria-label={t("prefs_users_table_base_url_header")}>{user.baseUrl}</TableCell>
<TableCell align="right">
<IconButton onClick={() => handleEditClick(user)}
aria-label={t("prefs_users_edit_button")}>
<EditIcon/>
</IconButton>
<IconButton onClick={() => handleDeleteClick(user)}
aria-label={t("prefs_users_delete_button")}>
<CloseIcon/>
</IconButton>
</TableCell>
</TableRow>
))}
</TableBody>
<UserDialog
key={`userEditDialog${dialogKey}`}
open={dialogOpen}
user={dialogUser}
users={props.users}
onCancel={handleDialogCancel}
onSubmit={handleDialogSubmit}
/>
</Table>
{session.exists() &&
<Typography>
xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
</Typography>
}
</div>
);
};
@ -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);
}
}
};

View file

@ -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 (