From e309775ac1357e74a06c6b2d1b755e7302ba099b Mon Sep 17 00:00:00 2001 From: Philipp Heckel Date: Sun, 23 Jan 2022 00:54:18 -0500 Subject: [PATCH] More auth CLi --- auth/auth.go | 10 ++- auth/auth_sqlite.go | 66 ++++++++++++------ cmd/app.go | 1 + cmd/user.go | 159 ++++++++++++++++++++++++++++++++++++++++++++ go.mod | 1 + go.sum | 1 + server/server.go | 4 +- util/util.go | 38 +++++++++++ 8 files changed, 257 insertions(+), 23 deletions(-) create mode 100644 cmd/user.go diff --git a/auth/auth.go b/auth/auth.go index ef521bd..56aa90d 100644 --- a/auth/auth.go +++ b/auth/auth.go @@ -2,12 +2,18 @@ package auth import "errors" -// auth is a generic interface to implement password-based authentication and authorization -type Auth interface { +// Auther is a generic interface to implement password-based authentication and authorization +type Auther interface { Authenticate(user, pass string) (*User, error) Authorize(user *User, topic string, perm Permission) error } +type Manager interface { + AddUser(username, password string, role Role) error + RemoveUser(username string) error + ChangePassword(username, password string) error +} + type User struct { Name string Role Role diff --git a/auth/auth_sqlite.go b/auth/auth_sqlite.go index cbaa0e4..65ca3cf 100644 --- a/auth/auth_sqlite.go +++ b/auth/auth_sqlite.go @@ -19,26 +19,13 @@ INSERT INTO user_topic VALUES ('marian','alerts',1,0); INSERT INTO user_topic VALUES ('','announcements',1,0); INSERT INTO user_topic VALUES ('','write-all',1,1); ---- -dabbling for CLI - ntfy user add phil --role=admin - ntfy user del phil - ntfy user change-pass phil - ntfy user allow phil mytopic - ntfy user allow phil mytopic --read-only - ntfy user deny phil mytopic - ntfy user list - phil (admin) - - read-write access to everything - ben (user) - - read-write access to a topic alerts - - read access to - everyone (no user) - - read-only access to topic announcements - - */ +const ( + bcryptCost = 11 +) + +// Auther-related queries const ( createAuthTablesQueries = ` BEGIN; @@ -69,13 +56,22 @@ const ( ` ) +// Manager-related queries +const ( + insertUser = `INSERT INTO user (user, pass, role) VALUES (?, ?, ?)` + updateUserPass = `UPDATE user SET pass = ? WHERE user = ?` + deleteUser = `DELETE FROM user WHERE user = ?` + deleteUserTopic = `DELETE FROM user_topic WHERE user = ?` +) + type SQLiteAuth struct { db *sql.DB defaultRead bool defaultWrite bool } -var _ Auth = (*SQLiteAuth)(nil) +var _ Auther = (*SQLiteAuth)(nil) +var _ Manager = (*SQLiteAuth)(nil) func NewSQLiteAuth(filename string, defaultRead, defaultWrite bool) (*SQLiteAuth, error) { db, err := sql.Open("sqlite3", filename) @@ -155,3 +151,35 @@ func (a *SQLiteAuth) resolvePerms(read, write bool, perm Permission) error { } return ErrUnauthorized } + +func (a *SQLiteAuth) AddUser(username, password string, role Role) error { + hash, err := bcrypt.GenerateFromPassword([]byte(password), bcryptCost) + if err != nil { + return err + } + if _, err = a.db.Exec(insertUser, username, hash, role); err != nil { + return err + } + return nil +} + +func (a *SQLiteAuth) RemoveUser(username string) error { + if _, err := a.db.Exec(deleteUser, username); err != nil { + return err + } + if _, err := a.db.Exec(deleteUserTopic, username); err != nil { + return err + } + return nil +} + +func (a *SQLiteAuth) ChangePassword(username, password string) error { + hash, err := bcrypt.GenerateFromPassword([]byte(password), bcryptCost) + if err != nil { + return err + } + if _, err := a.db.Exec(updateUserPass, hash, username); err != nil { + return err + } + return nil +} diff --git a/cmd/app.go b/cmd/app.go index 6ef4994..7b8ffd8 100644 --- a/cmd/app.go +++ b/cmd/app.go @@ -32,6 +32,7 @@ func New() *cli.App { cmdServe, cmdPublish, cmdSubscribe, + cmdUser, }, } } diff --git a/cmd/user.go b/cmd/user.go new file mode 100644 index 0000000..40f83ae --- /dev/null +++ b/cmd/user.go @@ -0,0 +1,159 @@ +package cmd + +import ( + "crypto/subtle" + "errors" + "fmt" + "github.com/urfave/cli/v2" + "github.com/urfave/cli/v2/altsrc" + "heckel.io/ntfy/auth" + "heckel.io/ntfy/util" + "strings" +) + +/* + +--- +dabbling for CLI + ntfy user allow phil mytopic + ntfy user allow phil mytopic --read-only + ntfy user deny phil mytopic + ntfy user list + phil (admin) + - read-write access to everything + ben (user) + - read-write access to a topic alerts + - read access to + everyone (no user) + - read-only access to topic announcements + +*/ + +var flagsUser = []cli.Flag{ + &cli.StringFlag{Name: "config", Aliases: []string{"c"}, EnvVars: []string{"NTFY_CONFIG_FILE"}, Value: "/etc/ntfy/server.yml", DefaultText: "/etc/ntfy/server.yml", Usage: "config file"}, + altsrc.NewStringFlag(&cli.StringFlag{Name: "auth-file", Aliases: []string{"H"}, EnvVars: []string{"NTFY_AUTH_FILE"}, Usage: "auth database file used for access control"}), + altsrc.NewStringFlag(&cli.StringFlag{Name: "auth-default-permissions", Aliases: []string{"p"}, EnvVars: []string{"NTFY_AUTH_DEFAULT_PERMISSIONS"}, Value: "read-write", Usage: "default permissions if no matching entries in the auth database are found"}), +} + +var cmdUser = &cli.Command{ + Name: "user", + Usage: "Manage users and access to topics", + UsageText: "ntfy user [add|del|...] ...", + Flags: flagsUser, + Before: initConfigFileInputSource("config", flagsUser), + Subcommands: []*cli.Command{ + { + Name: "add", + Aliases: []string{"a"}, + Usage: "add user to auth database", + Action: execUserAdd, + Flags: []cli.Flag{ + &cli.StringFlag{Name: "role", Aliases: []string{"r"}, Value: string(auth.RoleUser), Usage: "user role"}, + }, + }, + { + Name: "remove", + Aliases: []string{"del", "rm"}, + Usage: "remove user from auth database", + Action: execUserDel, + }, + { + Name: "change-pass", + Aliases: []string{"ch"}, + Usage: "change user password", + Action: execUserChangePass, + }, + }, +} + +func execUserAdd(c *cli.Context) error { + role := c.String("role") + if c.NArg() == 0 { + return errors.New("username expected, type 'ntfy user add --help' for help") + } else if role != string(auth.RoleUser) && role != string(auth.RoleAdmin) { + return errors.New("role must be either 'user' or 'admin'") + } + username := c.Args().Get(0) + password, err := readPassword(c) + if err != nil { + return err + } + manager, err := createAuthManager(c) + if err != nil { + return err + } + if err := manager.AddUser(username, password, auth.Role(role)); err != nil { + return err + } + fmt.Fprintf(c.App.ErrWriter, "User %s added with role %s\n", username, role) + return nil +} + +func execUserDel(c *cli.Context) error { + if c.NArg() == 0 { + return errors.New("username expected, type 'ntfy user del --help' for help") + } + username := c.Args().Get(0) + manager, err := createAuthManager(c) + if err != nil { + return err + } + if err := manager.RemoveUser(username); err != nil { + return err + } + fmt.Fprintf(c.App.ErrWriter, "User %s removed\n", username) + return nil +} + +func execUserChangePass(c *cli.Context) error { + if c.NArg() == 0 { + return errors.New("username expected, type 'ntfy user change-pass --help' for help") + } + username := c.Args().Get(0) + password, err := readPassword(c) + if err != nil { + return err + } + manager, err := createAuthManager(c) + if err != nil { + return err + } + if err := manager.ChangePassword(username, password); err != nil { + return err + } + fmt.Fprintf(c.App.ErrWriter, "Changed password for user %s\n", username) + return nil +} + +func createAuthManager(c *cli.Context) (auth.Manager, error) { + authFile := c.String("auth-file") + authDefaultPermissions := c.String("auth-default-permissions") + if authFile == "" { + return nil, errors.New("option auth-file not set; auth is unconfigured for this server") + } else if !util.FileExists(authFile) { + return nil, errors.New("auth-file does not exist; please start the server at least once to create it") + } else if !util.InStringList([]string{"read-write", "read-only", "deny-all"}, authDefaultPermissions) { + return nil, errors.New("if set, auth-default-permissions must start set to 'read-write', 'read-only' or 'deny-all'") + } + authDefaultRead := authDefaultPermissions == "read-write" || authDefaultPermissions == "read-only" + authDefaultWrite := authDefaultPermissions == "read-write" + return auth.NewSQLiteAuth(authFile, authDefaultRead, authDefaultWrite) +} + +func readPassword(c *cli.Context) (string, error) { + fmt.Fprint(c.App.ErrWriter, "Enter Password: ") + password, err := util.ReadPassword(c.App.Reader) + if err != nil { + return "", err + } + fmt.Fprintf(c.App.ErrWriter, "\r%s\rConfirm: ", strings.Repeat(" ", 25)) + confirm, err := util.ReadPassword(c.App.Reader) + if err != nil { + return "", err + } + fmt.Fprintf(c.App.ErrWriter, "\r%s\r", strings.Repeat(" ", 25)) + if subtle.ConstantTimeCompare(confirm, password) != 1 { + return "", errors.New("passwords do not match: try it again, but this time type slooowwwlly") + } + return string(password), nil +} diff --git a/go.mod b/go.mod index 3182d05..331d1da 100644 --- a/go.mod +++ b/go.mod @@ -18,6 +18,7 @@ require ( golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9 golang.org/x/oauth2 v0.0.0-20211104180415-d3ed0bb246c8 // indirect golang.org/x/sync v0.0.0-20210220032951-036812b2e83c + golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1 golang.org/x/time v0.0.0-20211116232009-f0f3c7e86c11 google.golang.org/api v0.63.0 gopkg.in/yaml.v2 v2.4.0 diff --git a/go.sum b/go.sum index 9a6ff81..fb33d8f 100644 --- a/go.sum +++ b/go.sum @@ -412,6 +412,7 @@ golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20211124211545-fe61309f8881/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211210111614-af8b64212486 h1:5hpz5aRr+W1erYCL5JRhSUBJRph7l9XkNveoExlrKYk= golang.org/x/sys v0.0.0-20211210111614-af8b64212486/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1 h1:v+OssWQX+hTHEmOBgwxdZxK4zHq3yOs8F9J7mk0PY8E= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= diff --git a/server/server.go b/server/server.go index ea06c44..887213a 100644 --- a/server/server.go +++ b/server/server.go @@ -47,7 +47,7 @@ type Server struct { firebase subscriber mailer mailer messages int64 - auth auth.Auth + auth auth.Auther cache cache fileCache *fileCache closeChan chan bool @@ -142,7 +142,7 @@ func New(conf *Config) (*Server, error) { return nil, err } } - var auther auth.Auth + var auther auth.Auther if conf.AuthFile != "" { auther, err = auth.NewSQLiteAuth(conf.AuthFile, conf.AuthDefaultRead, conf.AuthDefaultWrite) if err != nil { diff --git a/util/util.go b/util/util.go index c6e7623..ae4384e 100644 --- a/util/util.go +++ b/util/util.go @@ -4,6 +4,8 @@ import ( "errors" "fmt" "github.com/gabriel-vasile/mimetype" + "golang.org/x/term" + "io" "math/rand" "os" "regexp" @@ -202,3 +204,39 @@ func ParseSize(s string) (int64, error) { return int64(value), nil } } + +// ReadPassword will read a password from STDIN. If the terminal supports it, it will not print the +// input characters to the screen. If not, it'll just read using normal readline semantics (useful for testing). +func ReadPassword(in io.Reader) ([]byte, error) { + // If in is a file and a character device (a TTY), use term.ReadPassword + if f, ok := in.(*os.File); ok { + stat, err := f.Stat() + if err != nil { + return nil, err + } + if (stat.Mode() & os.ModeCharDevice) == os.ModeCharDevice { + password, err := term.ReadPassword(int(f.Fd())) // This is always going to be 0 + if err != nil { + return nil, err + } + return password, nil + } + } + + // Fallback: Manually read util \n if found, see #69 for details why this is so manual + password := make([]byte, 0) + buf := make([]byte, 1) + for { + _, err := in.Read(buf) + if err == io.EOF || buf[0] == '\n' { + break + } else if err != nil { + return nil, err + } else if len(password) > 10240 { + return nil, errors.New("passwords this long are not supported") + } + password = append(password, buf[0]) + } + + return password, nil +}