Merge pull request #1216 from hashicorp/userpass-update

Userpass: Update the password and policies associated to user
This commit is contained in:
Vishal Nayak 2016-03-16 14:58:28 -04:00
commit 2c0c901eac
8 changed files with 569 additions and 46 deletions

View file

@ -29,6 +29,8 @@ func Backend() *framework.Backend {
Paths: append([]*framework.Path{
pathUsers(&b),
pathUserPolicies(&b),
pathUserPassword(&b),
},
mfa.MFAPaths(b.Backend, pathLogin(&b))...,
),

View file

@ -81,7 +81,7 @@ func TestBackend_basic(t *testing.T) {
Backend: b,
Steps: []logicaltest.TestStep{
testAccStepUser(t, "web", "password", "foo"),
testAccStepLogin(t, "web", "password"),
testAccStepLogin(t, "web", "password", []string{"default", "foo"}),
},
})
}
@ -109,6 +109,98 @@ func TestBackend_userCrud(t *testing.T) {
})
}
func TestBackend_userCreateOperation(t *testing.T) {
b, err := Factory(&logical.BackendConfig{
Logger: nil,
System: &logical.StaticSystemView{
DefaultLeaseTTLVal: testSysTTL,
MaxLeaseTTLVal: testSysMaxTTL,
},
})
if err != nil {
t.Fatalf("Unable to create backend: %s", err)
}
logicaltest.Test(t, logicaltest.TestCase{
Backend: b,
Steps: []logicaltest.TestStep{
testUserCreateOperation(t, "web", "password", "foo"),
testAccStepLogin(t, "web", "password", []string{"default", "foo"}),
},
})
}
func TestBackend_passwordUpdate(t *testing.T) {
b, err := Factory(&logical.BackendConfig{
Logger: nil,
System: &logical.StaticSystemView{
DefaultLeaseTTLVal: testSysTTL,
MaxLeaseTTLVal: testSysMaxTTL,
},
})
if err != nil {
t.Fatalf("Unable to create backend: %s", err)
}
logicaltest.Test(t, logicaltest.TestCase{
Backend: b,
Steps: []logicaltest.TestStep{
testAccStepUser(t, "web", "password", "foo"),
testAccStepReadUser(t, "web", "foo"),
testAccStepLogin(t, "web", "password", []string{"default", "foo"}),
testUpdatePassword(t, "web", "newpassword"),
testAccStepLogin(t, "web", "newpassword", []string{"default", "foo"}),
},
})
}
func TestBackend_policiesUpdate(t *testing.T) {
b, err := Factory(&logical.BackendConfig{
Logger: nil,
System: &logical.StaticSystemView{
DefaultLeaseTTLVal: testSysTTL,
MaxLeaseTTLVal: testSysMaxTTL,
},
})
if err != nil {
t.Fatalf("Unable to create backend: %s", err)
}
logicaltest.Test(t, logicaltest.TestCase{
Backend: b,
Steps: []logicaltest.TestStep{
testAccStepUser(t, "web", "password", "foo"),
testAccStepReadUser(t, "web", "foo"),
testAccStepLogin(t, "web", "password", []string{"default", "foo"}),
testUpdatePolicies(t, "web", "foo,bar"),
testAccStepReadUser(t, "web", "foo,bar"),
testAccStepLogin(t, "web", "password", []string{"bar", "default", "foo"}),
},
})
}
func testUpdatePassword(t *testing.T, user, password string) logicaltest.TestStep {
return logicaltest.TestStep{
Operation: logical.UpdateOperation,
Path: "users/" + user + "/password",
Data: map[string]interface{}{
"password": password,
},
}
}
func testUpdatePolicies(t *testing.T, user, policies string) logicaltest.TestStep {
return logicaltest.TestStep{
Operation: logical.UpdateOperation,
Path: "users/" + user + "/policies",
Data: map[string]interface{}{
"policies": policies,
},
}
}
func testUsersWrite(t *testing.T, user string, data map[string]interface{}, expectError bool) logicaltest.TestStep {
return logicaltest.TestStep{
Operation: logical.UpdateOperation,
@ -139,7 +231,7 @@ func testLoginWrite(t *testing.T, user string, data map[string]interface{}, expe
}
}
func testAccStepLogin(t *testing.T, user string, pass string) logicaltest.TestStep {
func testAccStepLogin(t *testing.T, user string, pass string, policies []string) logicaltest.TestStep {
return logicaltest.TestStep{
Operation: logical.UpdateOperation,
Path: "login/" + user,
@ -148,7 +240,19 @@ func testAccStepLogin(t *testing.T, user string, pass string) logicaltest.TestSt
},
Unauthenticated: true,
Check: logicaltest.TestCheckAuth([]string{"foo"}),
Check: logicaltest.TestCheckAuth(policies),
}
}
func testUserCreateOperation(
t *testing.T, name string, password string, policies string) logicaltest.TestStep {
return logicaltest.TestStep{
Operation: logical.CreateOperation,
Path: "users/" + name,
Data: map[string]interface{}{
"password": password,
"policies": policies,
},
}
}

View file

@ -2,6 +2,7 @@ package userpass
import (
"crypto/subtle"
"fmt"
"strings"
"github.com/hashicorp/vault/logical"
@ -11,9 +12,9 @@ import (
func pathLogin(b *backend) *framework.Path {
return &framework.Path{
Pattern: "login/" + framework.GenericNameRegex("name"),
Pattern: "login/" + framework.GenericNameRegex("username"),
Fields: map[string]*framework.FieldSchema{
"name": &framework.FieldSchema{
"username": &framework.FieldSchema{
Type: framework.TypeString,
Description: "Username of the user.",
},
@ -35,16 +36,20 @@ func pathLogin(b *backend) *framework.Path {
func (b *backend) pathLogin(
req *logical.Request, d *framework.FieldData) (*logical.Response, error) {
username := strings.ToLower(d.Get("name").(string))
username := strings.ToLower(d.Get("username").(string))
password := d.Get("password").(string)
if password == "" {
return nil, fmt.Errorf("missing password")
}
// Get the user and validate auth
user, err := b.User(req.Storage, username)
user, err := b.user(req.Storage, username)
if err != nil {
return nil, err
}
if user == nil {
return logical.ErrorResponse("unknown username or password"), nil
return logical.ErrorResponse("username does not exist"), nil
}
// Check for a password match. Check for a hash collision for Vault 0.2+,
@ -78,7 +83,7 @@ func (b *backend) pathLogin(
func (b *backend) pathLoginRenew(
req *logical.Request, d *framework.FieldData) (*logical.Response, error) {
// Get the user
user, err := b.User(req.Storage, req.Auth.Metadata["username"])
user, err := b.user(req.Storage, req.Auth.Metadata["username"])
if err != nil {
return nil, err
}

View file

@ -0,0 +1,85 @@
package userpass
import (
"fmt"
"golang.org/x/crypto/bcrypt"
"github.com/hashicorp/vault/logical"
"github.com/hashicorp/vault/logical/framework"
)
func pathUserPassword(b *backend) *framework.Path {
return &framework.Path{
Pattern: "users/" + framework.GenericNameRegex("username") + "/password$",
Fields: map[string]*framework.FieldSchema{
"username": &framework.FieldSchema{
Type: framework.TypeString,
Description: "Username for this user.",
},
"password": &framework.FieldSchema{
Type: framework.TypeString,
Description: "Password for this user.",
},
},
ExistenceCheck: b.userPasswordExistenceCheck,
Callbacks: map[logical.Operation]framework.OperationFunc{
logical.UpdateOperation: b.pathUserPasswordUpdate,
},
HelpSynopsis: pathUserPasswordHelpSyn,
HelpDescription: pathUserPasswordHelpDesc,
}
}
// By always returning true, this endpoint will be enforced to be invoked only upon UpdateOperation.
// The existence of user will be checked in the operation handler.
func (b *backend) userPasswordExistenceCheck(req *logical.Request, data *framework.FieldData) (bool, error) {
return true, nil
}
func (b *backend) pathUserPasswordUpdate(
req *logical.Request, d *framework.FieldData) (*logical.Response, error) {
username := d.Get("username").(string)
userEntry, err := b.user(req.Storage, username)
if err != nil {
return nil, err
}
if userEntry == nil {
return nil, fmt.Errorf("username does not exist")
}
err = b.updateUserPassword(req, d, userEntry)
if err != nil {
return nil, err
}
return nil, b.setUser(req.Storage, username, userEntry)
}
func (b *backend) updateUserPassword(req *logical.Request, d *framework.FieldData, userEntry *UserEntry) error {
password := d.Get("password").(string)
if password == "" {
return fmt.Errorf("missing password")
}
// Generate a hash of the password
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return err
}
userEntry.PasswordHash = hash
return nil
}
const pathUserPasswordHelpSyn = `
Reset user's password.
`
const pathUserPasswordHelpDesc = `
This endpoint allows resetting the user's password.
`

View file

@ -0,0 +1,78 @@
package userpass
import (
"fmt"
"strings"
"github.com/hashicorp/vault/logical"
"github.com/hashicorp/vault/logical/framework"
)
func pathUserPolicies(b *backend) *framework.Path {
return &framework.Path{
Pattern: "users/" + framework.GenericNameRegex("username") + "/policies$",
Fields: map[string]*framework.FieldSchema{
"username": &framework.FieldSchema{
Type: framework.TypeString,
Description: "Username for this user.",
},
"policies": &framework.FieldSchema{
Type: framework.TypeString,
Description: "Comma-separated list of policies",
},
},
ExistenceCheck: b.userPoliciesExistenceCheck,
Callbacks: map[logical.Operation]framework.OperationFunc{
logical.UpdateOperation: b.pathUserPoliciesUpdate,
},
HelpSynopsis: pathUserPoliciesHelpSyn,
HelpDescription: pathUserPoliciesHelpDesc,
}
}
// By always returning true, this endpoint will be enforced to be invoked only upon UpdateOperation.
// The existence of user will be checked in the operation handler.
func (b *backend) userPoliciesExistenceCheck(req *logical.Request, data *framework.FieldData) (bool, error) {
return true, nil
}
func (b *backend) pathUserPoliciesUpdate(
req *logical.Request, d *framework.FieldData) (*logical.Response, error) {
username := d.Get("username").(string)
userEntry, err := b.user(req.Storage, username)
if err != nil {
return nil, err
}
if userEntry == nil {
return nil, fmt.Errorf("username does not exist")
}
err = b.updateUserPolicies(req, d, userEntry)
if err != nil {
return nil, err
}
return nil, b.setUser(req.Storage, username, userEntry)
}
func (b *backend) updateUserPolicies(req *logical.Request, d *framework.FieldData, userEntry *UserEntry) error {
policies := strings.Split(d.Get("policies").(string), ",")
for i, p := range policies {
policies[i] = strings.TrimSpace(p)
}
userEntry.Policies = policies
return nil
}
const pathUserPoliciesHelpSyn = `
Update the policies associated with the username.
`
const pathUserPoliciesHelpDesc = `
This endpoint allows updating the policies associated with the username.
`

View file

@ -7,14 +7,13 @@ import (
"github.com/hashicorp/vault/logical"
"github.com/hashicorp/vault/logical/framework"
"golang.org/x/crypto/bcrypt"
)
func pathUsers(b *backend) *framework.Path {
return &framework.Path{
Pattern: "users/" + framework.GenericNameRegex("name"),
Pattern: "users/" + framework.GenericNameRegex("username"),
Fields: map[string]*framework.FieldSchema{
"name": &framework.FieldSchema{
"username": &framework.FieldSchema{
Type: framework.TypeString,
Description: "Username for this user.",
},
@ -43,16 +42,32 @@ func pathUsers(b *backend) *framework.Path {
Callbacks: map[logical.Operation]framework.OperationFunc{
logical.DeleteOperation: b.pathUserDelete,
logical.ReadOperation: b.pathUserRead,
logical.UpdateOperation: b.pathUserWrite,
logical.UpdateOperation: b.pathUserWrite,
logical.CreateOperation: b.pathUserWrite,
},
ExistenceCheck: b.userExistenceCheck,
HelpSynopsis: pathUserHelpSyn,
HelpDescription: pathUserHelpDesc,
}
}
func (b *backend) User(s logical.Storage, n string) (*UserEntry, error) {
entry, err := s.Get("user/" + strings.ToLower(n))
func (b *backend) userExistenceCheck(req *logical.Request, data *framework.FieldData) (bool, error) {
userEntry, err := b.user(req.Storage, data.Get("username").(string))
if err != nil {
return false, err
}
return userEntry != nil, nil
}
func (b *backend) user(s logical.Storage, username string) (*UserEntry, error) {
if username == "" {
return nil, fmt.Errorf("missing username")
}
entry, err := s.Get("user/" + strings.ToLower(username))
if err != nil {
return nil, err
}
@ -68,9 +83,18 @@ func (b *backend) User(s logical.Storage, n string) (*UserEntry, error) {
return &result, nil
}
func (b *backend) setUser(s logical.Storage, username string, userEntry *UserEntry) error {
entry, err := logical.StorageEntryJSON("user/"+username, userEntry)
if err != nil {
return err
}
return s.Put(entry)
}
func (b *backend) pathUserDelete(
req *logical.Request, d *framework.FieldData) (*logical.Response, error) {
err := req.Storage.Delete("user/" + strings.ToLower(d.Get("name").(string)))
err := req.Storage.Delete("user/" + strings.ToLower(d.Get("username").(string)))
if err != nil {
return nil, err
}
@ -80,7 +104,7 @@ func (b *backend) pathUserDelete(
func (b *backend) pathUserRead(
req *logical.Request, d *framework.FieldData) (*logical.Response, error) {
user, err := b.User(req.Storage, strings.ToLower(d.Get("name").(string)))
user, err := b.user(req.Storage, strings.ToLower(d.Get("username").(string)))
if err != nil {
return nil, err
}
@ -95,43 +119,56 @@ func (b *backend) pathUserRead(
}, nil
}
func (b *backend) pathUserWrite(
req *logical.Request, d *framework.FieldData) (*logical.Response, error) {
name := strings.ToLower(d.Get("name").(string))
password := d.Get("password").(string)
policies := strings.Split(d.Get("policies").(string), ",")
for i, p := range policies {
policies[i] = strings.TrimSpace(p)
}
// Generate a hash of the password
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
func (b *backend) userCreateUpdate(req *logical.Request, d *framework.FieldData) (*logical.Response, error) {
username := strings.ToLower(d.Get("username").(string))
userEntry, err := b.user(req.Storage, username)
if err != nil {
return nil, err
}
// Due to existence check, user will only be nil if it's a create operation
if userEntry == nil {
userEntry = &UserEntry{}
}
ttlStr := d.Get("ttl").(string)
maxTTLStr := d.Get("max_ttl").(string)
ttl, maxTTL, err := b.SanitizeTTL(ttlStr, maxTTLStr)
if _, ok := d.GetOk("password"); ok {
err = b.updateUserPassword(req, d, userEntry)
if err != nil {
return nil, err
}
}
if _, ok := d.GetOk("policies"); ok {
err = b.updateUserPolicies(req, d, userEntry)
if err != nil {
return nil, err
}
}
ttlStr := userEntry.TTL.String()
if ttlStrRaw, ok := d.GetOk("ttl"); ok {
ttlStr = ttlStrRaw.(string)
}
maxTTLStr := userEntry.MaxTTL.String()
if maxTTLStrRaw, ok := d.GetOk("max_ttl"); ok {
maxTTLStr = maxTTLStrRaw.(string)
}
userEntry.TTL, userEntry.MaxTTL, err = b.SanitizeTTL(ttlStr, maxTTLStr)
if err != nil {
return logical.ErrorResponse(fmt.Sprintf("err: %s", err)), nil
}
// Store it
entry, err := logical.StorageEntryJSON("user/"+name, &UserEntry{
PasswordHash: hash,
Policies: policies,
TTL: ttl,
MaxTTL: maxTTL,
})
if err != nil {
return nil, err
}
if err := req.Storage.Put(entry); err != nil {
return nil, err
}
return nil, b.setUser(req.Storage, username, userEntry)
}
return nil, nil
func (b *backend) pathUserWrite(
req *logical.Request, d *framework.FieldData) (*logical.Response, error) {
password := d.Get("password").(string)
if req.Operation == logical.CreateOperation && password == "" {
return nil, fmt.Errorf("missing password")
}
return b.userCreateUpdate(req, d)
}
type UserEntry struct {

View file

@ -249,7 +249,7 @@ func (b *Backend) SanitizeTTL(ttlStr, maxTTLStr string) (ttl, maxTTL time.Durati
return 0, 0, fmt.Errorf("\"max_ttl\" value must be less than allowed max lease TTL value '%s'", sysMaxTTL.String())
}
}
if ttl > maxTTL {
if ttl > maxTTL && maxTTL != 0 {
ttl = maxTTL
}
return

View file

@ -92,3 +92,215 @@ $ vault write auth/userpass/users/mitchellh \
The above creates a new user "mitchellh" with the password "foo" that
will be associated with the "root" policy. This is the only configuration
necessary.
## API
### /auth/userpass/users/[username]
#### POST
<dl class="api">
<dt>Description</dt>
<dd>
Create a new user or update an existing user.
This path honors the distinction between the `create` and `update` capabilities inside ACL policies.
</dd>
<dt>Method</dt>
<dd>POST</dd>
<dt>URL</dt>
<dd>`/auth/userpass/users/<username>`</dd>
<dt>Parameters</dt>
<dd>
<ul>
<li>
<span class="param">username</span>
<span class="param-flags">required</span>
Username for this user.
</li>
</ul>
</dd>
<dd>
<ul>
<li>
<span class="param">password</span>
<span class="param-flags">required</span>
Password for this user.
</li>
</ul>
</dd>
<dd>
<ul>
<li>
<span class="param">policies</span>
<span class="param-flags">optional</span>
Comma-separated list of policies.
If set to empty string, only the `default` policy will be applicable to the user.
</li>
</ul>
</dd>
<dd>
<ul>
<li>
<span class="param">ttl</span>
<span class="param-flags">optional</span>
The lease duration which decides login expiration.
</li>
</ul>
</dd>
<dd>
<ul>
<li>
<span class="param">max_ttl</span>
<span class="param-flags">optional</span>
Maximum duration after which login should expire.
</li>
</ul>
</dd>
<dt>Returns</dt>
<dd>`204` response code.
</dd>
</dl>
### /auth/userpass/users/[username]/password
#### POST
<dl class="api">
<dt>Description</dt>
<dd>
Update the password for an existing user.
</dd>
<dt>Method</dt>
<dd>POST</dd>
<dt>URL</dt>
<dd>`/auth/userpass/users/<username>/password`</dd>
<dt>Parameters</dt>
<dd>
<ul>
<li>
<span class="param">username</span>
<span class="param-flags">required</span>
Username for this user.
</li>
</ul>
</dd>
<dd>
<ul>
<li>
<span class="param">password</span>
<span class="param-flags">required</span>
Password for this user.
</li>
</ul>
</dd>
<dt>Returns</dt>
<dd>`204` response code.
</dd>
</dl>
### /auth/userpass/users/[username]/policies
#### POST
<dl class="api">
<dt>Description</dt>
<dd>
Update the policies associated with an existing user.
</dd>
<dt>Method</dt>
<dd>POST</dd>
<dt>URL</dt>
<dd>`/auth/userpass/users/<username>/policies`</dd>
<dt>Parameters</dt>
<dd>
<ul>
<li>
<span class="param">username</span>
<span class="param-flags">required</span>
Username for this user.
</li>
</ul>
</dd>
<dd>
<ul>
<li>
<span class="param">policies</span>
<span class="param-flags">optional</span>
Comma-separated list of policies.
If this is field is not supplied, the policies will be unchanged.
If set to empty string, only the `default` policy will be applicable to the user.
</li>
</ul>
</dd>
<dt>Returns</dt>
<dd>`204` response code.
</dd>
</dl>
### /auth/userpass/login/[username]
#### POST
<dl class="api">
<dt>Description</dt>
<dd>
Update the policies associated with an existing user.
</dd>
<dt>Method</dt>
<dd>POST</dd>
<dt>URL</dt>
<dd>`/auth/userpass/users/<username>/policies`</dd>
<dt>Parameters</dt>
<dd>
<ul>
<li>
<span class="param">username</span>
<span class="param-flags">required</span>
Username for this user.
</li>
</ul>
</dd>
<dd>
<ul>
<li>
<span class="param">password</span>
<span class="param-flags">required</span>
Password for this user.
</li>
</ul>
</dd>
<dt>Returns</dt>
<dd>
```javascript
{
"lease_id": "",
"renewable": false,
"lease_duration": 0,
"data": null,
"warnings": null,
"auth": {
"client_token": "64d2a8f2-2a2f-5688-102b-e6088b76e344",
"accessor": "18bb8f89-826a-56ee-c65b-1736dc5ea27d",
"policies": ["default"],
"metadata": {
"username": "vishal"
},
"lease_duration": 7200,
"renewable": true
}
}
```
</dd>
</dl>