Skip to content

Commit

Permalink
Vault 8307 user lockout workflow oss (hashicorp#17951)
Browse files Browse the repository at this point in the history
* adding oss file changes

* check disabled and read values from config

* isUserLocked, getUserLockout Configurations, check user lock before login and return error

* remove stale entry from storage during read

* added failed login process workflow

* success workflow updated

* user lockouts external tests

* changing update to support delete

* provide access to alias look ahead function

* adding path alias lookahead

* adding tests

* added changelog

* added comments

* adding changes from ent branch

* adding lock to UpdateUserFailedLoginInfo

* fix return default bug
  • Loading branch information
akshya96 authored Dec 7, 2022
1 parent 7b837ed commit e1f7a7e
Show file tree
Hide file tree
Showing 6 changed files with 720 additions and 10 deletions.
3 changes: 3 additions & 0 deletions changelog/17951.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
```release-note:improvement
core: added changes for user lockout workflow.
```
4 changes: 4 additions & 0 deletions internalshared/configutil/userlockout.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@ type UserLockout struct {
DisableLockoutRaw interface{} `hcl:"disable_lockout"`
}

func GetSupportedUserLockoutsAuthMethods() []string {
return []string{"userpass", "approle", "ldap"}
}

func ParseUserLockouts(result *SharedConfig, list *ast.ObjectList) error {
var err error
result.UserLockouts = make([]*UserLockout, 0, len(list.Items))
Expand Down
36 changes: 36 additions & 0 deletions vault/core.go
Original file line number Diff line number Diff line change
Expand Up @@ -525,6 +525,9 @@ type Core struct {
// and login counter, last failed login time as value
userFailedLoginInfo map[FailedLoginUser]*FailedLoginInfo

// userFailedLoginInfoLock controls access to the userFailedLoginInfoMap
userFailedLoginInfoLock sync.RWMutex

enableMlock bool

// This can be used to trigger operations to stop running when Vault is
Expand Down Expand Up @@ -3455,6 +3458,39 @@ func (c *Core) DetermineRoleFromLoginRequest(mountPoint string, data map[string]
return resp.Data["role"].(string)
}

// aliasNameFromLoginRequest will determine the aliasName from the login Request
func (c *Core) aliasNameFromLoginRequest(ctx context.Context, req *logical.Request) (string, error) {
c.authLock.RLock()
defer c.authLock.RUnlock()
ns, err := namespace.FromContext(ctx)
if err != nil {
return "", err
}

// ns path is added while checking matching backend
mountPath := strings.TrimPrefix(req.MountPoint, ns.Path)

matchingBackend := c.router.MatchingBackend(ctx, mountPath)
if matchingBackend == nil || matchingBackend.Type() != logical.TypeCredential {
// pathLoginAliasLookAhead operation does not apply to this request
return "", nil
}

path := strings.ReplaceAll(req.Path, mountPath, "")

resp, err := matchingBackend.HandleRequest(ctx, &logical.Request{
MountPoint: req.MountPoint,
Path: path,
Operation: logical.AliasLookaheadOperation,
Data: req.Data,
Storage: c.router.MatchingStorageByAPIPath(ctx, req.Path),
})
if err != nil || resp.Auth.Alias == nil {
return "", nil
}
return resp.Auth.Alias.Name, nil
}

// ListMounts will provide a slice containing a deep copy each mount entry
func (c *Core) ListMounts() ([]*MountEntry, error) {
c.mountsLock.RLock()
Expand Down
326 changes: 326 additions & 0 deletions vault/external_tests/identity/userlockouts_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,326 @@
package identity

import (
"strings"
"testing"
"time"

"github.com/hashicorp/vault/api"
"github.com/hashicorp/vault/builtin/credential/userpass"
vaulthttp "github.com/hashicorp/vault/http"
"github.com/hashicorp/vault/sdk/logical"
"github.com/hashicorp/vault/vault"
)

// TestIdentityStore_UserLockoutTest tests that the user gets locked after
// more than 1 failed login request than the number specified for
// lockout threshold field in user lockout configuration. It also
// tests that the user gets unlocked after the duration specified
// for lockout duration field has passed
func TestIdentityStore_UserLockoutTest(t *testing.T) {
coreConfig := &vault.CoreConfig{
CredentialBackends: map[string]logical.Factory{
"userpass": userpass.Factory,
},
}
cluster := vault.NewTestCluster(t, coreConfig, &vault.TestClusterOptions{
HandlerFunc: vaulthttp.Handler,
})
cluster.Start()
defer cluster.Cleanup()
active := cluster.Cores[0].Client
standby := cluster.Cores[1].Client

err := active.Sys().EnableAuthWithOptions("userpass", &api.EnableAuthOptions{
Type: "userpass",
})
if err != nil {
t.Fatal(err)
}

// tune auth mount
userlockoutConfig := &api.UserLockoutConfigInput{
LockoutThreshold: "3",
LockoutDuration: "5s",
LockoutCounterResetDuration: "5s",
}
err = active.Sys().TuneMount("auth/userpass", api.MountConfigInput{
UserLockoutConfig: userlockoutConfig,
})
if err != nil {
t.Fatal(err)
}

// create a user for userpass
_, err = standby.Logical().Write("auth/userpass/users/bsmith", map[string]interface{}{
"password": "training",
})
if err != nil {
t.Fatal(err)
}

// login failure count 1
standby.Logical().Write("auth/userpass/login/bsmith", map[string]interface{}{
"password": "wrongPassword",
})

// login failure count 2
standby.Logical().Write("auth/userpass/login/bsmith", map[string]interface{}{
"password": "wrongPassword",
})

// login failure count 3
active.Logical().Write("auth/userpass/login/bsmith", map[string]interface{}{
"password": "wrongPassword",
})

// login : permission denied as user locked out
_, err = standby.Logical().Write("auth/userpass/login/bsmith", map[string]interface{}{
"password": "training",
})
if err == nil {
t.Fatal("expected login to fail as user locked out")
}
if !strings.Contains(err.Error(), logical.ErrPermissionDenied.Error()) {
t.Fatalf("expected to see permission denied error as user locked out, got %v", err)
}

time.Sleep(5 * time.Second)

// login with right password and wait for user to get unlocked
_, err = standby.Logical().Write("auth/userpass/login/bsmith", map[string]interface{}{
"password": "training",
})
if err != nil {
t.Fatal("expected login to succeed as user is unlocked")
}
}

// TestIdentityStore_UserFailedLoginMapResetOnSuccess tests that
// the user lockout feature is reset for a user after one successfull attempt
// after multiple failed login attempts (within lockout threshold)
func TestIdentityStore_UserFailedLoginMapResetOnSuccess(t *testing.T) {
coreConfig := &vault.CoreConfig{
CredentialBackends: map[string]logical.Factory{
"userpass": userpass.Factory,
},
}
cluster := vault.NewTestCluster(t, coreConfig, &vault.TestClusterOptions{
HandlerFunc: vaulthttp.Handler,
})
cluster.Start()
defer cluster.Cleanup()

client := cluster.Cores[0].Client

err := client.Sys().EnableAuthWithOptions("userpass", &api.EnableAuthOptions{
Type: "userpass",
})
if err != nil {
t.Fatal(err)
}

// tune auth mount
userlockoutConfig := &api.UserLockoutConfigInput{
LockoutThreshold: "3",
LockoutDuration: "5s",
LockoutCounterResetDuration: "5s",
}
err = client.Sys().TuneMount("auth/userpass", api.MountConfigInput{
UserLockoutConfig: userlockoutConfig,
})
if err != nil {
t.Fatal(err)
}

// create a user for userpass
_, err = client.Logical().Write("auth/userpass/users/bsmith", map[string]interface{}{
"password": "training",
})
if err != nil {
t.Fatal(err)
}

// login failure count 1
client.Logical().Write("auth/userpass/login/bsmith", map[string]interface{}{
"password": "wrongPassword",
})

// login failure count 2
client.Logical().Write("auth/userpass/login/bsmith", map[string]interface{}{
"password": "wrongPassword",
})

// login with right credentials - successful login
// entry for this user is removed from userFailedLoginInfo map
_, err = client.Logical().Write("auth/userpass/login/bsmith", map[string]interface{}{
"password": "training",
})
if err != nil {
t.Fatal(err)
}

// login failure count 3, is now count 1 after successful login
client.Logical().Write("auth/userpass/login/bsmith", map[string]interface{}{
"password": "wrongPassword",
})

// login failure count 4, is now count 2 after successful login
// error should not be permission denied as user not locked out
_, err = client.Logical().Write("auth/userpass/login/bsmith", map[string]interface{}{
"password": "wrongPassword",
})
if err == nil {
t.Fatal("expected login to fail due to wrong credentials")
}
if !strings.Contains(err.Error(), "invalid username or password") {
t.Fatalf("expected to see invalid username or password error as user is not locked out, got %v", err)
}
}

// TestIdentityStore_DisableUserLockoutTest tests that user login will
// fail when supplied with wrong credentials. If the user is locked,
// it returns permission denied. In this case, it returns invalid user
// credentials error as the user lockout feature is disabled and the
// user did not get locked after multiple failed login attempts
func TestIdentityStore_DisableUserLockoutTest(t *testing.T) {
coreConfig := &vault.CoreConfig{
CredentialBackends: map[string]logical.Factory{
"userpass": userpass.Factory,
},
}
cluster := vault.NewTestCluster(t, coreConfig, &vault.TestClusterOptions{
HandlerFunc: vaulthttp.Handler,
})
cluster.Start()
defer cluster.Cleanup()

active := cluster.Cores[0].Client
standby := cluster.Cores[1].Client

err := active.Sys().EnableAuthWithOptions("userpass", &api.EnableAuthOptions{
Type: "userpass",
})
if err != nil {
t.Fatal(err)
}

// tune auth mount
disableLockout := true
userlockoutConfig := &api.UserLockoutConfigInput{
LockoutThreshold: "3",
DisableLockout: &disableLockout,
}
err = active.Sys().TuneMount("auth/userpass", api.MountConfigInput{
UserLockoutConfig: userlockoutConfig,
})
if err != nil {
t.Fatal(err)
}

// create a userpass user
_, err = standby.Logical().Write("auth/userpass/users/bsmith", map[string]interface{}{
"password": "training",
})
if err != nil {
t.Fatal(err)
}

// login failure count 1
standby.Logical().Write("auth/userpass/login/bsmith", map[string]interface{}{
"password": "wrongPassword",
})

// login failure count 2
standby.Logical().Write("auth/userpass/login/bsmith", map[string]interface{}{
"password": "wrongPassword",
})

// login failure count 3
active.Logical().Write("auth/userpass/login/bsmith", map[string]interface{}{
"password": "wrongPassword",
})

// login failure count 4
_, err = standby.Logical().Write("auth/userpass/login/bsmith", map[string]interface{}{
"password": "wrongPassword",
})
if err == nil {
t.Fatal("expected login to fail due to wrong credentials")
}
if !strings.Contains(err.Error(), "invalid username or password") {
t.Fatalf("expected to see invalid username or password error as user is not locked out, got %v", err)
}
}

// TestIdentityStore_LockoutCounterResetTest tests that the user lockout counter
// for a user is reset after no failed login attempts for a duration
// as specified for lockout counter reset field in user lockout configuration
func TestIdentityStore_LockoutCounterResetTest(t *testing.T) {
coreConfig := &vault.CoreConfig{
CredentialBackends: map[string]logical.Factory{
"userpass": userpass.Factory,
},
}
cluster := vault.NewTestCluster(t, coreConfig, &vault.TestClusterOptions{
HandlerFunc: vaulthttp.Handler,
})
cluster.Start()
defer cluster.Cleanup()
active := cluster.Cores[0].Client
standby := cluster.Cores[1].Client

err := active.Sys().EnableAuthWithOptions("userpass", &api.EnableAuthOptions{
Type: "userpass",
})
if err != nil {
t.Fatal(err)
}

// tune auth mount
userlockoutConfig := &api.UserLockoutConfigInput{
LockoutThreshold: "3",
LockoutCounterResetDuration: "5s",
}
err = active.Sys().TuneMount("auth/userpass", api.MountConfigInput{
UserLockoutConfig: userlockoutConfig,
})
if err != nil {
t.Fatal(err)
}

// create a user for userpass
_, err = standby.Logical().Write("auth/userpass/users/bsmith", map[string]interface{}{
"password": "training",
})
if err != nil {
t.Fatal(err)
}

// login failure count 1
standby.Logical().Write("auth/userpass/login/bsmith", map[string]interface{}{
"password": "wrongPassword",
})
// login failure count 2
standby.Logical().Write("auth/userpass/login/bsmith", map[string]interface{}{
"password": "wrongPassword",
})

// set sleep timer to reset login counter
time.Sleep(5 * time.Second)

// login failure 3, count should be reset, this will be treated as failed count 1
active.Logical().Write("auth/userpass/login/bsmith", map[string]interface{}{
"password": "wrongPassword",
})
// login failure 4, this will be treated as failed count 2
_, err = standby.Logical().Write("auth/userpass/login/bsmith", map[string]interface{}{
"password": "wrongPassword",
})
if err == nil {
t.Fatal("expected login to fail due to wrong credentials")
}
if !strings.Contains(err.Error(), "invalid username or password") {
t.Fatalf("expected to see invalid username or password error as user is not locked out, got %v", err)
}
}
Loading

0 comments on commit e1f7a7e

Please sign in to comment.