mirror of
https://github.com/optim-enterprises-bv/vault.git
synced 2025-10-29 17:52:32 +00:00
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:
@@ -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"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
92
builtin/credential/userpass/path_user_password_test.go
Normal file
92
builtin/credential/userpass/path_user_password_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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
3
changelog/25862.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
```release-note: enhancement
|
||||
auth/userpass: Support supplying of a pre-hashed password instead of the password itself
|
||||
```
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user