diff --git a/cmd/serve.go b/cmd/serve.go index 36bef4bd..3b1b4ef7 100644 --- a/cmd/serve.go +++ b/cmd/serve.go @@ -550,7 +550,7 @@ func parseUsers(usersRaw []string) ([]*user.User, error) { role := user.Role(strings.TrimSpace(parts[2])) if !user.AllowedUsername(username) { return nil, fmt.Errorf("invalid auth-users: %s, username invalid", userLine) - } else if err := user.AllowedPasswordHash(passwordHash); err != nil { + } else if err := user.ValidPasswordHash(passwordHash); err != nil { return nil, fmt.Errorf("invalid auth-users: %s, %s", userLine, err.Error()) } else if !user.AllowedRole(role) { return nil, fmt.Errorf("invalid auth-users: %s, role %s is not allowed, allowed roles are 'admin' or 'user'", userLine, role) @@ -625,7 +625,7 @@ func parseTokens(users []*user.User, tokensRaw []string) (map[string][]*user.Tok return nil, fmt.Errorf("invalid auth-tokens: %s, username %s invalid", tokenLine, username) } token := strings.TrimSpace(parts[1]) - if !user.AllowedToken(token) { + if !user.ValidToken(token) { return nil, fmt.Errorf("invalid auth-tokens: %s, token %s invalid, use 'ntfy token generate' to generate a random token", tokenLine, token) } var label string diff --git a/cmd/token.go b/cmd/token.go index 25399c89..614d47e2 100644 --- a/cmd/token.go +++ b/cmd/token.go @@ -222,6 +222,6 @@ func execTokenList(c *cli.Context) error { } func execTokenGenerate(c *cli.Context) error { - fmt.Println(user.GenerateToken()) + fmt.Fprintln(c.App.Writer, user.GenerateToken()) return nil } diff --git a/cmd/user.go b/cmd/user.go index 49504a94..84d8df15 100644 --- a/cmd/user.go +++ b/cmd/user.go @@ -306,19 +306,15 @@ func execUserChangeRole(c *cli.Context) error { } func execUserHash(c *cli.Context) error { - manager, err := createUserManager(c) - if err != nil { - return err - } password, err := readPasswordAndConfirm(c) if err != nil { return err } - hash, err := manager.HashPassword(password) + hash, err := user.HashPassword(password) if err != nil { return fmt.Errorf("failed to hash password: %w", err) } - fmt.Fprintf(c.App.Writer, "%s\n", string(hash)) + fmt.Fprintln(c.App.Writer, hash) return nil } diff --git a/user/manager.go b/user/manager.go index 8b5409c7..5e68b177 100644 --- a/user/manager.go +++ b/user/manager.go @@ -1015,11 +1015,11 @@ func (a *Manager) addUserTx(tx *sql.Tx, username, password string, role Role, ha var err error = nil if hashed { hash = password - if err := AllowedPasswordHash(hash); err != nil { + if err := ValidPasswordHash(hash); err != nil { return err } } else { - hash, err = a.HashPassword(password) + hash, err = hashPassword(password, a.config.BcryptCost) if err != nil { return err } @@ -1365,11 +1365,11 @@ func (a *Manager) changePasswordTx(tx *sql.Tx, username, password string, hashed var err error if hashed { hash = password - if err := AllowedPasswordHash(hash); err != nil { + if err := ValidPasswordHash(hash); err != nil { return err } } else { - hash, err = a.HashPassword(password) + hash, err = hashPassword(password, a.config.BcryptCost) if err != nil { return err } @@ -1697,15 +1697,6 @@ func (a *Manager) readTier(rows *sql.Rows) (*Tier, error) { }, nil } -// HashPassword hashes the given password using bcrypt with the configured cost -func (a *Manager) HashPassword(password string) (string, error) { - hash, err := bcrypt.GenerateFromPassword([]byte(password), a.config.BcryptCost) - if err != nil { - return "", err - } - return string(hash), nil -} - // Close closes the underlying database func (a *Manager) Close() error { return a.db.Close() diff --git a/user/types.go b/user/types.go index ba213b2d..726d40e0 100644 --- a/user/types.go +++ b/user/types.go @@ -4,9 +4,7 @@ import ( "errors" "github.com/stripe/stripe-go/v74" "heckel.io/ntfy/v2/log" - "heckel.io/ntfy/v2/util" "net/netip" - "regexp" "strings" "time" ) @@ -244,58 +242,6 @@ const ( everyoneID = "u_everyone" ) -var ( - allowedUsernameRegex = regexp.MustCompile(`^[-_.+@a-zA-Z0-9]+$`) // Does not include Everyone (*) - allowedTopicRegex = regexp.MustCompile(`^[-_A-Za-z0-9]{1,64}$`) // No '*' - allowedTopicPatternRegex = regexp.MustCompile(`^[-_*A-Za-z0-9]{1,64}$`) // Adds '*' for wildcards! - allowedTierRegex = regexp.MustCompile(`^[-_A-Za-z0-9]{1,64}$`) - allowedTokenRegex = regexp.MustCompile(`^tk_[-_A-Za-z0-9]{29}$`) // Must be tokenLength-len(tokenPrefix) -) - -// 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) -} - -// AllowedTopic returns true if the given topic name is valid -func AllowedTopic(topic string) bool { - return allowedTopicRegex.MatchString(topic) -} - -// AllowedTopicPattern returns true if the given topic pattern is valid; this includes the wildcard character (*) -func AllowedTopicPattern(topic string) bool { - return allowedTopicPatternRegex.MatchString(topic) -} - -// AllowedTier returns true if the given tier name is valid -func AllowedTier(tier string) bool { - return allowedTierRegex.MatchString(tier) -} - -// AllowedPasswordHash checks if the given password hash is a valid bcrypt hash -func AllowedPasswordHash(hash string) error { - if !strings.HasPrefix(hash, "$2a$") && !strings.HasPrefix(hash, "$2b$") && !strings.HasPrefix(hash, "$2y$") { - return ErrPasswordHashInvalid - } - return nil -} - -// AllowedToken returns true if the given token matches the naming convention -func AllowedToken(token string) bool { - return allowedTokenRegex.MatchString(token) -} - -// GenerateToken generates a new token with a prefix and a fixed length -// Lowercase only to support "+@" email addresses -func GenerateToken() string { - return util.RandomLowerStringPrefix(tokenPrefix, tokenLength) -} - // Error constants used by the package var ( ErrUnauthenticated = errors.New("unauthenticated") diff --git a/user/util.go b/user/util.go new file mode 100644 index 00000000..e0da4b27 --- /dev/null +++ b/user/util.go @@ -0,0 +1,73 @@ +package user + +import ( + "golang.org/x/crypto/bcrypt" + "heckel.io/ntfy/v2/util" + "regexp" + "strings" +) + +var ( + allowedUsernameRegex = regexp.MustCompile(`^[-_.+@a-zA-Z0-9]+$`) // Does not include Everyone (*) + allowedTopicRegex = regexp.MustCompile(`^[-_A-Za-z0-9]{1,64}$`) // No '*' + allowedTopicPatternRegex = regexp.MustCompile(`^[-_*A-Za-z0-9]{1,64}$`) // Adds '*' for wildcards! + allowedTierRegex = regexp.MustCompile(`^[-_A-Za-z0-9]{1,64}$`) + allowedTokenRegex = regexp.MustCompile(`^tk_[-_A-Za-z0-9]{29}$`) // Must be tokenLength-len(tokenPrefix) +) + +// 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) +} + +// AllowedTopic returns true if the given topic name is valid +func AllowedTopic(topic string) bool { + return allowedTopicRegex.MatchString(topic) +} + +// AllowedTopicPattern returns true if the given topic pattern is valid; this includes the wildcard character (*) +func AllowedTopicPattern(topic string) bool { + return allowedTopicPatternRegex.MatchString(topic) +} + +// AllowedTier returns true if the given tier name is valid +func AllowedTier(tier string) bool { + return allowedTierRegex.MatchString(tier) +} + +// ValidPasswordHash checks if the given password hash is a valid bcrypt hash +func ValidPasswordHash(hash string) error { + if !strings.HasPrefix(hash, "$2a$") && !strings.HasPrefix(hash, "$2b$") && !strings.HasPrefix(hash, "$2y$") { + return ErrPasswordHashInvalid + } + return nil +} + +// ValidToken returns true if the given token matches the naming convention +func ValidToken(token string) bool { + return allowedTokenRegex.MatchString(token) +} + +// GenerateToken generates a new token with a prefix and a fixed length +// Lowercase only to support "+@" email addresses +func GenerateToken() string { + return util.RandomLowerStringPrefix(tokenPrefix, tokenLength) +} + +// HashPassword hashes the given password using bcrypt with the configured cost +func HashPassword(password string) (string, error) { + return hashPassword(password, DefaultUserPasswordBcryptCost) +} + +func hashPassword(password string, cost int) (string, error) { + hash, err := bcrypt.GenerateFromPassword([]byte(password), cost) + if err != nil { + return "", err + } + return string(hash), nil +}