diff --git a/cmd/serve.go b/cmd/serve.go index ef37ee6f..882debdc 100644 --- a/cmd/serve.go +++ b/cmd/serve.go @@ -543,8 +543,8 @@ func parseProvisionUsers(usersRaw []string) ([]*user.User, error) { role := user.Role(strings.TrimSpace(parts[2])) if !user.AllowedUsername(username) { return nil, fmt.Errorf("invalid auth-provision-users: %s, username invalid", userLine) - } else if passwordHash == "" { - return nil, fmt.Errorf("invalid auth-provision-users: %s, password hash cannot be empty", userLine) + } else if err := user.AllowedPasswordHash(passwordHash); err != nil { + return nil, fmt.Errorf("invalid auth-provision-users: %s, %s", userLine, err.Error()) } else if !user.AllowedRole(role) { return nil, fmt.Errorf("invalid auth-provision-users: %s, role %s is not allowed, allowed roles are 'admin' or 'user'", userLine, role) } diff --git a/cmd/user.go b/cmd/user.go index 0a6e24a1..49504a94 100644 --- a/cmd/user.go +++ b/cmd/user.go @@ -133,6 +133,22 @@ as messages per day, attachment file sizes, etc. Example: ntfy user change-tier phil pro # Change tier to "pro" for user "phil" ntfy user change-tier phil - # Remove tier from user "phil" entirely +`, + }, + { + Name: "hash", + Usage: "Create password hash for a predefined user", + UsageText: "ntfy user hash", + Action: execUserHash, + Description: `Asks for a password and creates a bcrypt password hash. + +This command is useful to create a password hash for a user, which can then be used +for predefined users in the server config file, in auth-provision-users. + +Example: + $ ntfy user hash + (asks for password and confirmation) + $2a$10$YLiO8U21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C `, }, { @@ -289,6 +305,23 @@ func execUserChangeRole(c *cli.Context) error { return nil } +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) + if err != nil { + return fmt.Errorf("failed to hash password: %w", err) + } + fmt.Fprintf(c.App.Writer, "%s\n", string(hash)) + return nil +} + func execUserChangeTier(c *cli.Context) error { username := c.Args().Get(0) tier := c.Args().Get(1) diff --git a/user/manager.go b/user/manager.go index 09db145e..ecef8747 100644 --- a/user/manager.go +++ b/user/manager.go @@ -981,12 +981,15 @@ func (a *Manager) addUserTx(tx *sql.Tx, username, password string, role Role, ha if !AllowedUsername(username) || !AllowedRole(role) { return ErrInvalidArgument } - var hash []byte + var hash string var err error = nil if hashed { - hash = []byte(password) + hash = password + if err := AllowedPasswordHash(hash); err != nil { + return err + } } else { - hash, err = bcrypt.GenerateFromPassword([]byte(password), a.config.BcryptCost) + hash, err = a.HashPassword(password) if err != nil { return err } @@ -1328,12 +1331,15 @@ func (a *Manager) ChangePassword(username, password string, hashed bool) error { } func (a *Manager) changePasswordTx(tx *sql.Tx, username, password string, hashed bool) error { - var hash []byte + var hash string var err error if hashed { - hash = []byte(password) + hash = password + if err := AllowedPasswordHash(hash); err != nil { + return err + } } else { - hash, err = bcrypt.GenerateFromPassword([]byte(password), a.config.BcryptCost) + hash, err = a.HashPassword(password) if err != nil { return err } @@ -1640,6 +1646,15 @@ 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() @@ -1681,7 +1696,7 @@ func (a *Manager) maybeProvisionUsersAndAccess() error { if err := a.addUserTx(tx, user.Name, user.Hash, user.Role, true, true); err != nil && !errors.Is(err, ErrUserExists) { return fmt.Errorf("failed to add provisioned user %s: %v", user.Name, err) } - } else if existingUser.Hash != user.Hash || existingUser.Role != user.Role { + } else if existingUser.Provisioned && (existingUser.Hash != user.Hash || existingUser.Role != user.Role) { log.Tag(tag).Info("Updating provisioned user %s", user.Name) if err := a.changePasswordTx(tx, user.Name, user.Hash, true); err != nil { return fmt.Errorf("failed to change password for provisioned user %s: %v", user.Name, err) diff --git a/user/manager_test.go b/user/manager_test.go index c2887ff3..94bd1b97 100644 --- a/user/manager_test.go +++ b/user/manager_test.go @@ -340,7 +340,7 @@ func TestManager_UserManagement(t *testing.T) { func TestManager_ChangePassword(t *testing.T) { a := newTestManager(t, PermissionDenyAll) require.Nil(t, a.AddUser("phil", "phil", RoleAdmin, false)) - require.Nil(t, a.AddUser("jane", "$2b$10$OyqU72muEy7VMd1SAU2Iru5IbeSMgrtCGHu/fWLmxL1MwlijQXWbG", RoleUser, true)) + require.Nil(t, a.AddUser("jane", "$2a$10$OyqU72muEy7VMd1SAU2Iru5IbeSMgrtCGHu/fWLmxL1MwlijQXWbG", RoleUser, true)) _, err := a.Authenticate("phil", "phil") require.Nil(t, err) @@ -354,7 +354,7 @@ func TestManager_ChangePassword(t *testing.T) { _, err = a.Authenticate("phil", "newpass") require.Nil(t, err) - require.Nil(t, a.ChangePassword("jane", "$2b$10$CNaCW.q1R431urlbQ5Drh.zl48TiiOeJSmZgfcswkZiPbJGQ1ApSS", true)) + require.Nil(t, a.ChangePassword("jane", "$2a$10$CNaCW.q1R431urlbQ5Drh.zl48TiiOeJSmZgfcswkZiPbJGQ1ApSS", true)) _, err = a.Authenticate("jane", "jane") require.Equal(t, ErrUnauthenticated, err) _, err = a.Authenticate("jane", "newpass") diff --git a/user/types.go b/user/types.go index 90eeefce..aaf77d1f 100644 --- a/user/types.go +++ b/user/types.go @@ -274,6 +274,14 @@ 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 +} + // Error constants used by the package var ( ErrUnauthenticated = errors.New("unauthenticated") @@ -281,6 +289,7 @@ var ( ErrInvalidArgument = errors.New("invalid argument") ErrUserNotFound = errors.New("user not found") ErrUserExists = errors.New("user already exists") + ErrPasswordHashInvalid = errors.New("password hash but be a bcrypt hash, use 'ntfy user hash' to generate") ErrTierNotFound = errors.New("tier not found") ErrTokenNotFound = errors.New("token not found") ErrPhoneNumberNotFound = errors.New("phone number not found")