More auth CLi

This commit is contained in:
Philipp Heckel 2022-01-23 00:54:18 -05:00
parent f388fd9c90
commit e309775ac1
8 changed files with 257 additions and 23 deletions

View file

@ -2,12 +2,18 @@ package auth
import "errors" import "errors"
// auth is a generic interface to implement password-based authentication and authorization // Auther is a generic interface to implement password-based authentication and authorization
type Auth interface { type Auther interface {
Authenticate(user, pass string) (*User, error) Authenticate(user, pass string) (*User, error)
Authorize(user *User, topic string, perm Permission) 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 { type User struct {
Name string Name string
Role Role Role Role

View file

@ -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 ('','announcements',1,0);
INSERT INTO user_topic VALUES ('','write-all',1,1); 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 ( const (
createAuthTablesQueries = ` createAuthTablesQueries = `
BEGIN; 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 { type SQLiteAuth struct {
db *sql.DB db *sql.DB
defaultRead bool defaultRead bool
defaultWrite bool defaultWrite bool
} }
var _ Auth = (*SQLiteAuth)(nil) var _ Auther = (*SQLiteAuth)(nil)
var _ Manager = (*SQLiteAuth)(nil)
func NewSQLiteAuth(filename string, defaultRead, defaultWrite bool) (*SQLiteAuth, error) { func NewSQLiteAuth(filename string, defaultRead, defaultWrite bool) (*SQLiteAuth, error) {
db, err := sql.Open("sqlite3", filename) db, err := sql.Open("sqlite3", filename)
@ -155,3 +151,35 @@ func (a *SQLiteAuth) resolvePerms(read, write bool, perm Permission) error {
} }
return ErrUnauthorized 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
}

View file

@ -32,6 +32,7 @@ func New() *cli.App {
cmdServe, cmdServe,
cmdPublish, cmdPublish,
cmdSubscribe, cmdSubscribe,
cmdUser,
}, },
} }
} }

159
cmd/user.go Normal file
View file

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

1
go.mod
View file

@ -18,6 +18,7 @@ require (
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9 golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9
golang.org/x/oauth2 v0.0.0-20211104180415-d3ed0bb246c8 // indirect golang.org/x/oauth2 v0.0.0-20211104180415-d3ed0bb246c8 // indirect
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c 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 golang.org/x/time v0.0.0-20211116232009-f0f3c7e86c11
google.golang.org/api v0.63.0 google.golang.org/api v0.63.0
gopkg.in/yaml.v2 v2.4.0 gopkg.in/yaml.v2 v2.4.0

1
go.sum
View file

@ -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-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 h1:5hpz5aRr+W1erYCL5JRhSUBJRph7l9XkNveoExlrKYk=
golang.org/x/sys v0.0.0-20211210111614-af8b64212486/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 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/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.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=

View file

@ -47,7 +47,7 @@ type Server struct {
firebase subscriber firebase subscriber
mailer mailer mailer mailer
messages int64 messages int64
auth auth.Auth auth auth.Auther
cache cache cache cache
fileCache *fileCache fileCache *fileCache
closeChan chan bool closeChan chan bool
@ -142,7 +142,7 @@ func New(conf *Config) (*Server, error) {
return nil, err return nil, err
} }
} }
var auther auth.Auth var auther auth.Auther
if conf.AuthFile != "" { if conf.AuthFile != "" {
auther, err = auth.NewSQLiteAuth(conf.AuthFile, conf.AuthDefaultRead, conf.AuthDefaultWrite) auther, err = auth.NewSQLiteAuth(conf.AuthFile, conf.AuthDefaultRead, conf.AuthDefaultWrite)
if err != nil { if err != nil {

View file

@ -4,6 +4,8 @@ import (
"errors" "errors"
"fmt" "fmt"
"github.com/gabriel-vasile/mimetype" "github.com/gabriel-vasile/mimetype"
"golang.org/x/term"
"io"
"math/rand" "math/rand"
"os" "os"
"regexp" "regexp"
@ -202,3 +204,39 @@ func ParseSize(s string) (int64, error) {
return int64(value), nil 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
}