Support pre-hashed passwords with userpass backend (#25862)

* allows use of pre-hashed passwords with userpass backend

* Remove unneeded error

* Single error check after switch

* use param name quoted in error message

* updated test for quoted param in error

* white space fixes for markdown doc

* More whitespace fixes

* added changelog

* Password/pre-hashed password are only required on 'create' operation

* docs indentation

* Update website/content/docs/auth/userpass.mdx

Co-authored-by: Sarah Chavis <62406755+schavis@users.noreply.github.com>

* Updated docs

* Check length of hash too

* Update builtin/credential/userpass/path_user_password_test.go

:)

Co-authored-by: Kuba Wieczorek <kuba.wieczorek@hashicorp.com>

---------

Co-authored-by: Sarah Chavis <62406755+schavis@users.noreply.github.com>
Co-authored-by: Kuba Wieczorek <kuba.wieczorek@hashicorp.com>
This commit is contained in:
Peter Wilson
2024-03-12 18:16:11 +00:00
committed by GitHub
parent 0d71b2a3dd
commit a311735761
7 changed files with 198 additions and 33 deletions

View File

@@ -12,7 +12,7 @@ import (
"time"
"github.com/go-test/deep"
sockaddr "github.com/hashicorp/go-sockaddr"
"github.com/hashicorp/go-sockaddr"
logicaltest "github.com/hashicorp/vault/helper/testhelpers/logical"
"github.com/hashicorp/vault/sdk/helper/policyutil"
"github.com/hashicorp/vault/sdk/helper/tokenutil"

View File

@@ -6,15 +6,37 @@ package userpass
import (
"context"
"fmt"
"strings"
"github.com/hashicorp/vault/sdk/framework"
"github.com/hashicorp/vault/sdk/logical"
"golang.org/x/crypto/bcrypt"
)
const (
pathUserPasswordHelpDesc = `
This endpoint allows resetting the user's password.
`
pathUserPasswordHelpSyn = `
Reset user's password.
`
// The name of the username parameter supplied via the API.
paramUsername = "username"
// The name of the password parameter supplied via the API.
paramPassword = "password"
// The name of the password hash parameter supplied via the API.
paramPasswordHash = "password_hash"
// The expected length of any hash generated by bcrypt.
bcryptHashLength = 60
)
func pathUserPassword(b *backend) *framework.Path {
return &framework.Path{
Pattern: "users/" + framework.GenericNameRegex("username") + "/password$",
Pattern: "users/" + framework.GenericNameRegex(paramUsername) + "/password$",
DisplayAttrs: &framework.DisplayAttributes{
OperationPrefix: operationPrefixUserpass,
@@ -23,15 +45,20 @@ func pathUserPassword(b *backend) *framework.Path {
},
Fields: map[string]*framework.FieldSchema{
"username": {
paramUsername: {
Type: framework.TypeString,
Description: "Username for this user.",
},
"password": {
paramPassword: {
Type: framework.TypeString,
Description: "Password for this user.",
},
paramPasswordHash: {
Type: framework.TypeString,
Description: "Pre-hashed password in bcrypt format for this user.",
},
},
Callbacks: map[logical.Operation]framework.OperationFunc{
@@ -44,7 +71,7 @@ func pathUserPassword(b *backend) *framework.Path {
}
func (b *backend) pathUserPasswordUpdate(ctx context.Context, req *logical.Request, d *framework.FieldData) (*logical.Response, error) {
username := d.Get("username").(string)
username := d.Get(paramUsername).(string)
userEntry, err := b.user(ctx, req.Storage, username)
if err != nil {
@@ -65,24 +92,51 @@ func (b *backend) pathUserPasswordUpdate(ctx context.Context, req *logical.Reque
return nil, b.setUser(ctx, req.Storage, username, userEntry)
}
func (b *backend) updateUserPassword(req *logical.Request, d *framework.FieldData, userEntry *UserEntry) (error, error) {
password := d.Get("password").(string)
if password == "" {
return fmt.Errorf("missing password"), nil
func (b *backend) updateUserPassword(_ *logical.Request, d *framework.FieldData, userEntry *UserEntry) (error, error) {
password := d.Get(paramPassword).(string)
passwordHash := d.Get(paramPasswordHash).(string)
var hash []byte
var err error
switch {
case password != "" && passwordHash != "":
return fmt.Errorf("%q and %q cannot be supplied together", paramPassword, paramPasswordHash), nil
case password == "" && passwordHash == "":
return fmt.Errorf("%q or %q must be supplied", paramPassword, paramPasswordHash), nil
case password != "":
hash, err = bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
case passwordHash != "":
hash, err = parsePasswordHash(passwordHash)
}
// Generate a hash of the password
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return nil, err
}
userEntry.PasswordHash = hash
return nil, nil
}
const pathUserPasswordHelpSyn = `
Reset user's password.
`
// parsePasswordHash is used to parse a password hash that follows the bcrypt standard.
// It examines the prefix of the string supplied to verify it complies with a supported
// version before returning the string in bytes.
func parsePasswordHash(passwordHash string) ([]byte, error) {
var res []byte
const pathUserPasswordHelpDesc = `
This endpoint allows resetting the user's password.
`
switch {
// All bcrypt hashes should be the same length.
case len(passwordHash) != bcryptHashLength:
return nil, fmt.Errorf("password hash has incorrect length")
// See: https://en.wikipedia.org/wiki/Bcrypt for versioning history.
case strings.HasPrefix(passwordHash, "$2a$"), // $2a% (non-ASCII character support)
strings.HasPrefix(passwordHash, "$2y$"), // $2y$ (PHP fixed)
strings.HasPrefix(passwordHash, "$2b$"): // $2b$ (truncation fix)
res = []byte(passwordHash)
default:
return nil, fmt.Errorf("password hash has incorrect prefix")
}
return res, nil
}

View File

@@ -0,0 +1,92 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: BUSL-1.1
package userpass
import (
"testing"
"golang.org/x/crypto/bcrypt"
"github.com/stretchr/testify/require"
)
// TestUserPass_ParseHash ensures that we correctly validate password hashes that
// conform to the bcrypt standard based on the prefix of the hash.
func TestUserPass_ParseHash(t *testing.T) {
t.Parallel()
tests := map[string]struct {
input string
isErrorExpected bool
expectedErrorMessage string
}{
"too-short": {
input: "too short",
isErrorExpected: true,
expectedErrorMessage: "password hash has incorrect length",
},
"60-spaces": {
input: " ",
isErrorExpected: true,
expectedErrorMessage: "password hash has incorrect prefix",
},
"jibberish": {
input: "jibberfishjibberfishjibberfishjibberfishjibberfishjibberfish",
isErrorExpected: true,
expectedErrorMessage: "password hash has incorrect prefix",
},
"non-ascii-prefix": {
input: "$2a$qwertyjibberfishjibberfishjibberfishjibberfishjibberfish",
isErrorExpected: false,
},
"truncation-prefix": {
input: "$2b$qwertyjibberfishjibberfishjibberfishjibberfishjibberfish",
isErrorExpected: false,
},
"php-only-fixed-prefix": {
input: "$2y$qwertyjibberfishjibberfishjibberfishjibberfishjibberfish",
isErrorExpected: false,
},
"php-only-existing": {
input: "$2x$qwertyjibberfishjibberfishjibberfishjibberfishjibberfish",
isErrorExpected: true,
expectedErrorMessage: "password hash has incorrect prefix",
},
}
for name, tc := range tests {
name := name
tc := tc
t.Run(name, func(t *testing.T) {
t.Parallel()
got, err := parsePasswordHash(tc.input)
switch {
case tc.isErrorExpected:
require.EqualError(t, err, tc.expectedErrorMessage)
default:
require.NoError(t, err)
require.Equal(t, tc.input, string(got))
}
})
}
}
// TestUserPass_BcryptHashLength ensures that using the bcrypt library to generate
// a hash from a password always produces the same length.
func TestUserPass_BcryptHashLength(t *testing.T) {
t.Parallel()
tests := []string{
"",
" ",
"foo",
"this is a long password woo",
}
for _, input := range tests {
hash, err := bcrypt.GenerateFromPassword([]byte(input), bcrypt.DefaultCost)
require.NoError(t, err)
require.Len(t, hash, bcryptHashLength)
}
}

View File

@@ -9,7 +9,7 @@ import (
"strings"
"time"
sockaddr "github.com/hashicorp/go-sockaddr"
"github.com/hashicorp/go-sockaddr"
"github.com/hashicorp/vault/sdk/framework"
"github.com/hashicorp/vault/sdk/helper/tokenutil"
"github.com/hashicorp/vault/sdk/logical"
@@ -37,7 +37,7 @@ func pathUsersList(b *backend) *framework.Path {
func pathUsers(b *backend) *framework.Path {
p := &framework.Path{
Pattern: "users/" + framework.GenericNameRegex("username"),
Pattern: "users/" + framework.GenericNameRegex(paramUsername),
DisplayAttrs: &framework.DisplayAttributes{
OperationPrefix: operationPrefixUserpass,
@@ -47,12 +47,12 @@ func pathUsers(b *backend) *framework.Path {
},
Fields: map[string]*framework.FieldSchema{
"username": {
paramUsername: {
Type: framework.TypeString,
Description: "Username for this user.",
},
"password": {
paramPassword: {
Type: framework.TypeString,
Description: "Password for this user.",
DisplayAttrs: &framework.DisplayAttributes{
@@ -60,6 +60,14 @@ func pathUsers(b *backend) *framework.Path {
},
},
paramPasswordHash: {
Type: framework.TypeString,
Description: "Pre-hashed password in bcrypt format for this user.",
DisplayAttrs: &framework.DisplayAttributes{
Sensitive: true,
},
},
"policies": {
Type: framework.TypeCommaStringSlice,
Description: tokenutil.DeprecationText("token_policies"),
@@ -103,7 +111,7 @@ func pathUsers(b *backend) *framework.Path {
}
func (b *backend) userExistenceCheck(ctx context.Context, req *logical.Request, d *framework.FieldData) (bool, error) {
userEntry, err := b.user(ctx, req.Storage, d.Get("username").(string))
userEntry, err := b.user(ctx, req.Storage, d.Get(paramUsername).(string))
if err != nil {
return false, err
}
@@ -163,7 +171,7 @@ func (b *backend) pathUserList(ctx context.Context, req *logical.Request, d *fra
}
func (b *backend) pathUserDelete(ctx context.Context, req *logical.Request, d *framework.FieldData) (*logical.Response, error) {
err := req.Storage.Delete(ctx, "user/"+strings.ToLower(d.Get("username").(string)))
err := req.Storage.Delete(ctx, "user/"+strings.ToLower(d.Get(paramUsername).(string)))
if err != nil {
return nil, err
}
@@ -172,7 +180,7 @@ func (b *backend) pathUserDelete(ctx context.Context, req *logical.Request, d *f
}
func (b *backend) pathUserRead(ctx context.Context, req *logical.Request, d *framework.FieldData) (*logical.Response, error) {
user, err := b.user(ctx, req.Storage, strings.ToLower(d.Get("username").(string)))
user, err := b.user(ctx, req.Storage, strings.ToLower(d.Get(paramUsername).(string)))
if err != nil {
return nil, err
}
@@ -203,7 +211,7 @@ func (b *backend) pathUserRead(ctx context.Context, req *logical.Request, d *fra
}
func (b *backend) userCreateUpdate(ctx context.Context, req *logical.Request, d *framework.FieldData) (*logical.Response, error) {
username := strings.ToLower(d.Get("username").(string))
username := strings.ToLower(d.Get(paramUsername).(string))
userEntry, err := b.user(ctx, req.Storage, username)
if err != nil {
return nil, err
@@ -217,7 +225,7 @@ func (b *backend) userCreateUpdate(ctx context.Context, req *logical.Request, d
return logical.ErrorResponse(err.Error()), logical.ErrInvalidRequest
}
if _, ok := d.GetOk("password"); ok {
if d.Get(paramPassword).(string) != "" || d.Get(paramPasswordHash).(string) != "" {
userErr, intErr := b.updateUserPassword(req, d, userEntry)
if intErr != nil {
return nil, intErr
@@ -250,11 +258,18 @@ func (b *backend) userCreateUpdate(ctx context.Context, req *logical.Request, d
}
func (b *backend) pathUserWrite(ctx context.Context, req *logical.Request, d *framework.FieldData) (*logical.Response, error) {
password := d.Get("password").(string)
if req.Operation == logical.CreateOperation && password == "" {
return logical.ErrorResponse("missing password"), logical.ErrInvalidRequest
password := d.Get(paramPassword).(string)
passwordHash := d.Get(paramPasswordHash).(string)
switch {
case password != "" && passwordHash != "":
return logical.ErrorResponse(fmt.Sprintf("%q and %q cannot be supplied together", paramPassword, paramPasswordHash)), logical.ErrInvalidRequest
case password == "" && passwordHash == "" && req.Operation == logical.CreateOperation:
// Password or pre-hashed password are only required on 'create'.
return logical.ErrorResponse(fmt.Sprintf("%q or %q must be supplied", paramPassword, paramPasswordHash)), logical.ErrInvalidRequest
default:
return b.userCreateUpdate(ctx, req, d)
}
return b.userCreateUpdate(ctx, req, d)
}
type UserEntry struct {

3
changelog/25862.txt Normal file
View File

@@ -0,0 +1,3 @@
```release-note: enhancement
auth/userpass: Support supplying of a pre-hashed password instead of the password itself
```

View File

@@ -27,8 +27,8 @@ Create a new user or update an existing user. This path honors the distinction b
### Parameters
- `username` `(string: <required>)` The username for the user. Accepted characters: alphanumeric plus "_", "-", "." (underscore, hyphen and period); username cannot begin with a hyphen, nor can it begin or end with a period.
- `password` `(string: <required>)` - The password for the user. Only required
when creating the user.
- `password` `(string: <required if password_hash is not given>)` - Password for the current user. Only required when creating the user. Mutually exclusive with `password_hash`.
- `password_hash` `(string: <required if password is not given>)` - Pre-hashed password for the current user in bcrypt format. Mutually exclusive with `password`.
@include 'tokenfields.mdx'

View File

@@ -69,7 +69,8 @@ management tool.
$ vault auth enable userpass
```
This enables the userpass auth method at `auth/userpass`. To enable it at a different path, use the `-path` flag:
Enable the `userpass` auth method at the default `auth/userpass` path.
You can choose to enable the auth method at a different path with the `-path` flag:
```shell-session
$ vault auth enable -path=<path> userpass