Display name sync
This commit is contained in:
parent
7ae8049438
commit
2fb4bd4975
12 changed files with 897 additions and 793 deletions
|
@ -355,6 +355,8 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visit
|
||||||
return s.handleAccountSettingsChange(w, r, v)
|
return s.handleAccountSettingsChange(w, r, v)
|
||||||
} else if r.Method == http.MethodPost && r.URL.Path == accountSubscriptionPath {
|
} else if r.Method == http.MethodPost && r.URL.Path == accountSubscriptionPath {
|
||||||
return s.handleAccountSubscriptionAdd(w, r, v)
|
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) {
|
} else if r.Method == http.MethodDelete && accountSubscriptionSingleRegex.MatchString(r.URL.Path) {
|
||||||
return s.handleAccountSubscriptionDelete(w, r, v)
|
return s.handleAccountSubscriptionDelete(w, r, v)
|
||||||
} else if r.Method == http.MethodGet && r.URL.Path == matrixPushPath {
|
} else if r.Method == http.MethodGet && r.URL.Path == matrixPushPath {
|
||||||
|
|
|
@ -5,6 +5,7 @@ import (
|
||||||
"errors"
|
"errors"
|
||||||
"heckel.io/ntfy/user"
|
"heckel.io/ntfy/user"
|
||||||
"heckel.io/ntfy/util"
|
"heckel.io/ntfy/util"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -244,40 +245,75 @@ func (s *Server) handleAccountSubscriptionAdd(w http.ResponseWriter, r *http.Req
|
||||||
if v.user == nil {
|
if v.user == nil {
|
||||||
return errors.New("no user")
|
return errors.New("no user")
|
||||||
}
|
}
|
||||||
w.Header().Set("Content-Type", "application/json")
|
newSubscription, err := readJSONBody[user.Subscription](r.Body)
|
||||||
w.Header().Set("Access-Control-Allow-Origin", "*") // FIXME remove this
|
|
||||||
body, err := util.Peek(r.Body, 4096) // FIXME
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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 {
|
if v.user.Prefs == nil {
|
||||||
v.user.Prefs = &user.Prefs{}
|
v.user.Prefs = &user.Prefs{}
|
||||||
}
|
}
|
||||||
newSubscription.ID = "" // Client cannot set ID
|
newSubscription.ID = "" // Client cannot set ID
|
||||||
for _, subscription := range v.user.Prefs.Subscriptions {
|
for _, subscription := range v.user.Prefs.Subscriptions {
|
||||||
if newSubscription.BaseURL == subscription.BaseURL && newSubscription.Topic == subscription.Topic {
|
if newSubscription.BaseURL == subscription.BaseURL && newSubscription.Topic == subscription.Topic {
|
||||||
newSubscription = *subscription
|
newSubscription = subscription
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if newSubscription.ID == "" {
|
if newSubscription.ID == "" {
|
||||||
newSubscription.ID = util.RandomString(16)
|
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 {
|
if err := s.userManager.ChangeSettings(v.user); err != nil {
|
||||||
return err
|
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 {
|
if err := json.NewEncoder(w).Encode(newSubscription); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return nil
|
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 {
|
func (s *Server) handleAccountSubscriptionDelete(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||||
if v.user == nil {
|
if v.user == nil {
|
||||||
return errors.New("no user")
|
return errors.New("no user")
|
||||||
|
@ -306,3 +342,16 @@ func (s *Server) handleAccountSubscriptionDelete(w http.ResponseWriter, r *http.
|
||||||
}
|
}
|
||||||
return nil
|
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
|
||||||
|
}
|
||||||
|
|
672
user/manager.go
672
user/manager.go
|
@ -1,176 +1,592 @@
|
||||||
// Package auth deals with authentication and authorization against topics
|
|
||||||
package user
|
package user
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"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
|
const (
|
||||||
type Manager interface {
|
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
|
// 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
|
// returns in constant-ish time, regardless of whether the user exists or the password is
|
||||||
// correct or incorrect.
|
// correct or incorrect.
|
||||||
Authenticate(username, password string) (*User, error)
|
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
|
||||||
|
}
|
||||||
|
|
||||||
AuthenticateToken(token string) (*User, error)
|
func (a *SQLiteManager) AuthenticateToken(token string) (*User, error) {
|
||||||
CreateToken(user *User) (*Token, error)
|
user, err := a.userByToken(token)
|
||||||
ExtendToken(user *User) (*Token, error)
|
if err != nil {
|
||||||
RemoveToken(user *User) error
|
return nil, ErrUnauthenticated
|
||||||
RemoveExpiredTokens() error
|
}
|
||||||
ChangeSettings(user *User) error
|
user.Token = token
|
||||||
EnqueueStats(user *User)
|
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
|
// 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.
|
// permission. The user param may be nil to signal an anonymous user.
|
||||||
Authorize(user *User, topic string, perm Permission) error
|
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
|
// AddUser adds a user with the given username, password and role. The password should be hashed
|
||||||
// before it is stored in a persistence layer.
|
// before it is stored in a persistence layer.
|
||||||
AddUser(username, password string, role Role) error
|
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
|
// 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.
|
// if the user did not exist in the first place.
|
||||||
RemoveUser(username string) error
|
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 ("*").
|
// Users returns a list of users. It always also returns the Everyone user ("*").
|
||||||
Users() ([]*User, error)
|
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.
|
// 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.
|
// You may also pass Everyone to retrieve the anonymous user and its Grant list.
|
||||||
User(username string) (*User, error)
|
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
|
// ChangePassword changes a user's password
|
||||||
ChangePassword(username, password string) error
|
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,
|
// 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.
|
// all existing access control entries (Grant) are removed, since they are no longer needed.
|
||||||
ChangeRole(username string, role Role) error
|
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
|
// 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 (*).
|
// read/write access to a topic. The parameter topicPattern may include wildcards (*).
|
||||||
AllowAccess(username string, topicPattern string, read bool, write bool) error
|
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
|
// 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 (*).
|
// empty) for an entire user. The parameter topicPattern may include wildcards (*).
|
||||||
ResetAccess(username string, topicPattern string) error
|
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
|
// DefaultAccess returns the default read/write access if no access control entry matches
|
||||||
DefaultAccess() (read bool, write bool)
|
func (a *SQLiteManager) DefaultAccess() (read bool, write bool) {
|
||||||
|
return a.defaultRead, a.defaultWrite
|
||||||
}
|
}
|
||||||
|
|
||||||
// User is a struct that represents a user
|
func toSQLWildcard(s string) string {
|
||||||
type User struct {
|
return strings.ReplaceAll(s, "*", "%")
|
||||||
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 {
|
func fromSQLWildcard(s string) string {
|
||||||
Value string
|
return strings.ReplaceAll(s, "%", "*")
|
||||||
Expires int64
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type Prefs struct {
|
func setupAuthDB(db *sql.DB) error {
|
||||||
Language string `json:"language,omitempty"`
|
// If 'schemaVersion' table does not exist, this must be a new database
|
||||||
Notification *NotificationPrefs `json:"notification,omitempty"`
|
rowsSV, err := db.Query(selectSchemaVersionQuery)
|
||||||
Subscriptions []*Subscription `json:"subscriptions,omitempty"`
|
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
type PlanCode string
|
func setupNewAuthDB(db *sql.DB) error {
|
||||||
|
if _, err := db.Exec(createAuthTablesQueries); err != nil {
|
||||||
const (
|
return err
|
||||||
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"`
|
|
||||||
}
|
}
|
||||||
|
if _, err := db.Exec(insertSchemaVersion, currentSchemaVersion); err != nil {
|
||||||
type Subscription struct {
|
return err
|
||||||
ID string `json:"id"`
|
|
||||||
BaseURL string `json:"base_url"`
|
|
||||||
Topic string `json:"topic"`
|
|
||||||
}
|
}
|
||||||
|
return nil
|
||||||
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")
|
|
||||||
)
|
|
||||||
|
|
|
@ -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
177
user/types.go
Normal 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")
|
||||||
|
)
|
|
@ -175,6 +175,25 @@ class AccountApi {
|
||||||
return subscription;
|
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) {
|
async deleteSubscription(remoteId) {
|
||||||
const url = accountSubscriptionSingleUrl(config.baseUrl, remoteId);
|
const url = accountSubscriptionSingleUrl(config.baseUrl, remoteId);
|
||||||
console.log(`[AccountApi] Removing user subscription ${url}`);
|
console.log(`[AccountApi] Removing user subscription ${url}`);
|
||||||
|
|
|
@ -1,3 +1,5 @@
|
||||||
|
import routes from "../components/routes";
|
||||||
|
|
||||||
class Session {
|
class Session {
|
||||||
store(username, token) {
|
store(username, token) {
|
||||||
localStorage.setItem("user", username);
|
localStorage.setItem("user", username);
|
||||||
|
@ -9,6 +11,11 @@ class Session {
|
||||||
localStorage.removeItem("token");
|
localStorage.removeItem("token");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
resetAndRedirect(url) {
|
||||||
|
this.reset();
|
||||||
|
window.location.href = url;
|
||||||
|
}
|
||||||
|
|
||||||
exists() {
|
exists() {
|
||||||
return this.username() && this.token();
|
return this.username() && this.token();
|
||||||
}
|
}
|
||||||
|
|
|
@ -36,12 +36,15 @@ class SubscriptionManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
async syncFromRemote(remoteSubscriptions) {
|
async syncFromRemote(remoteSubscriptions) {
|
||||||
|
console.log(`[SubscriptionManager] Syncing subscriptions from remote`, remoteSubscriptions);
|
||||||
|
|
||||||
// Add remote subscriptions
|
// Add remote subscriptions
|
||||||
let remoteIds = [];
|
let remoteIds = [];
|
||||||
for (let i = 0; i < remoteSubscriptions.length; i++) {
|
for (let i = 0; i < remoteSubscriptions.length; i++) {
|
||||||
const remote = remoteSubscriptions[i];
|
const remote = remoteSubscriptions[i];
|
||||||
const local = await this.add(remote.base_url, remote.topic);
|
const local = await this.add(remote.base_url, remote.topic);
|
||||||
await this.setRemoteId(local.id, remote.id);
|
await this.setRemoteId(local.id, remote.id);
|
||||||
|
await this.setDisplayName(local.id, remote.display_name);
|
||||||
remoteIds.push(remote.id);
|
remoteIds.push(remote.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -49,7 +52,7 @@ class SubscriptionManager {
|
||||||
const localSubscriptions = await db.subscriptions.toArray();
|
const localSubscriptions = await db.subscriptions.toArray();
|
||||||
for (let i = 0; i < localSubscriptions.length; i++) {
|
for (let i = 0; i < localSubscriptions.length; i++) {
|
||||||
const local = localSubscriptions[i];
|
const local = localSubscriptions[i];
|
||||||
if (local.remoteId && !remoteIds.includes(local.remoteId)) {
|
if (!local.remoteId || !remoteIds.includes(local.remoteId)) {
|
||||||
await this.remove(local.id);
|
await this.remove(local.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -204,7 +204,7 @@ const SettingsIcons = (props) => {
|
||||||
|
|
||||||
return (
|
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/>}
|
{subscription.mutedUntil ? <NotificationsOffIcon/> : <NotificationsIcon/>}
|
||||||
</IconButton>
|
</IconButton>
|
||||||
<IconButton color="inherit" size="large" edge="end" ref={anchorRef} onClick={handleToggleOpen} aria-label={t("action_bar_toggle_action_menu")}>
|
<IconButton color="inherit" size="large" edge="end" ref={anchorRef} onClick={handleToggleOpen} aria-label={t("action_bar_toggle_action_menu")}>
|
||||||
|
|
|
@ -319,6 +319,7 @@ const UserTable = (props) => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
|
<div>
|
||||||
<Table size="small" aria-label={t("prefs_users_table")}>
|
<Table size="small" aria-label={t("prefs_users_table")}>
|
||||||
<TableHead>
|
<TableHead>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
|
@ -333,13 +334,16 @@ const UserTable = (props) => {
|
||||||
key={user.baseUrl}
|
key={user.baseUrl}
|
||||||
sx={{'&:last-child td, &:last-child th': {border: 0}}}
|
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 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 aria-label={t("prefs_users_table_base_url_header")}>{user.baseUrl}</TableCell>
|
||||||
<TableCell align="right">
|
<TableCell align="right">
|
||||||
<IconButton onClick={() => handleEditClick(user)} aria-label={t("prefs_users_edit_button")}>
|
<IconButton onClick={() => handleEditClick(user)}
|
||||||
|
aria-label={t("prefs_users_edit_button")}>
|
||||||
<EditIcon/>
|
<EditIcon/>
|
||||||
</IconButton>
|
</IconButton>
|
||||||
<IconButton onClick={() => handleDeleteClick(user)} aria-label={t("prefs_users_delete_button")}>
|
<IconButton onClick={() => handleDeleteClick(user)}
|
||||||
|
aria-label={t("prefs_users_delete_button")}>
|
||||||
<CloseIcon/>
|
<CloseIcon/>
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
@ -355,6 +359,12 @@ const UserTable = (props) => {
|
||||||
onSubmit={handleDialogSubmit}
|
onSubmit={handleDialogSubmit}
|
||||||
/>
|
/>
|
||||||
</Table>
|
</Table>
|
||||||
|
{session.exists() &&
|
||||||
|
<Typography>
|
||||||
|
xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||||
|
</Typography>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -672,8 +682,7 @@ const maybeUpdateAccountSettings = async (payload) => {
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log(`[Preferences] Error updating account settings`, e);
|
console.log(`[Preferences] Error updating account settings`, e);
|
||||||
if ((e instanceof UnauthorizedError)) {
|
if ((e instanceof UnauthorizedError)) {
|
||||||
session.reset();
|
session.resetAndRedirect(routes.login);
|
||||||
window.location.href = routes.login;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
@ -15,6 +15,9 @@ import subscriptionManager from "../app/SubscriptionManager";
|
||||||
import poller from "../app/Poller";
|
import poller from "../app/Poller";
|
||||||
import DialogFooter from "./DialogFooter";
|
import DialogFooter from "./DialogFooter";
|
||||||
import {useTranslation} from "react-i18next";
|
import {useTranslation} from "react-i18next";
|
||||||
|
import accountApi, {UnauthorizedError} from "../app/AccountApi";
|
||||||
|
import session from "../app/Session";
|
||||||
|
import routes from "./routes";
|
||||||
|
|
||||||
const SubscriptionSettingsDialog = (props) => {
|
const SubscriptionSettingsDialog = (props) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
@ -23,6 +26,17 @@ const SubscriptionSettingsDialog = (props) => {
|
||||||
const fullScreen = useMediaQuery(theme.breakpoints.down('sm'));
|
const fullScreen = useMediaQuery(theme.breakpoints.down('sm'));
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
await subscriptionManager.setDisplayName(subscription.id, displayName);
|
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();
|
props.onClose();
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
|
|
Loading…
Reference in a new issue