diff --git a/api/sys_mounts.go b/api/sys_mounts.go index 75173b4d8f2d..f55133cec4c6 100644 --- a/api/sys_mounts.go +++ b/api/sys_mounts.go @@ -254,20 +254,20 @@ type MountInput struct { } type MountConfigInput struct { - Options map[string]string `json:"options" mapstructure:"options"` - DefaultLeaseTTL string `json:"default_lease_ttl" mapstructure:"default_lease_ttl"` - Description *string `json:"description,omitempty" mapstructure:"description"` - MaxLeaseTTL string `json:"max_lease_ttl" mapstructure:"max_lease_ttl"` - ForceNoCache bool `json:"force_no_cache" mapstructure:"force_no_cache"` - AuditNonHMACRequestKeys []string `json:"audit_non_hmac_request_keys,omitempty" mapstructure:"audit_non_hmac_request_keys"` - AuditNonHMACResponseKeys []string `json:"audit_non_hmac_response_keys,omitempty" mapstructure:"audit_non_hmac_response_keys"` - ListingVisibility string `json:"listing_visibility,omitempty" mapstructure:"listing_visibility"` - PassthroughRequestHeaders []string `json:"passthrough_request_headers,omitempty" mapstructure:"passthrough_request_headers"` - AllowedResponseHeaders []string `json:"allowed_response_headers,omitempty" mapstructure:"allowed_response_headers"` - TokenType string `json:"token_type,omitempty" mapstructure:"token_type"` - AllowedManagedKeys []string `json:"allowed_managed_keys,omitempty" mapstructure:"allowed_managed_keys"` - PluginVersion string `json:"plugin_version,omitempty"` - + Options map[string]string `json:"options" mapstructure:"options"` + DefaultLeaseTTL string `json:"default_lease_ttl" mapstructure:"default_lease_ttl"` + Description *string `json:"description,omitempty" mapstructure:"description"` + MaxLeaseTTL string `json:"max_lease_ttl" mapstructure:"max_lease_ttl"` + ForceNoCache bool `json:"force_no_cache" mapstructure:"force_no_cache"` + AuditNonHMACRequestKeys []string `json:"audit_non_hmac_request_keys,omitempty" mapstructure:"audit_non_hmac_request_keys"` + AuditNonHMACResponseKeys []string `json:"audit_non_hmac_response_keys,omitempty" mapstructure:"audit_non_hmac_response_keys"` + ListingVisibility string `json:"listing_visibility,omitempty" mapstructure:"listing_visibility"` + PassthroughRequestHeaders []string `json:"passthrough_request_headers,omitempty" mapstructure:"passthrough_request_headers"` + AllowedResponseHeaders []string `json:"allowed_response_headers,omitempty" mapstructure:"allowed_response_headers"` + TokenType string `json:"token_type,omitempty" mapstructure:"token_type"` + AllowedManagedKeys []string `json:"allowed_managed_keys,omitempty" mapstructure:"allowed_managed_keys"` + PluginVersion string `json:"plugin_version,omitempty"` + UserLockoutConfig *UserLockoutConfigInput `json:"user_lockout_config,omitempty"` // Deprecated: This field will always be blank for newer server responses. PluginName string `json:"plugin_name,omitempty" mapstructure:"plugin_name"` } @@ -289,21 +289,35 @@ type MountOutput struct { } type MountConfigOutput struct { - DefaultLeaseTTL int `json:"default_lease_ttl" mapstructure:"default_lease_ttl"` - MaxLeaseTTL int `json:"max_lease_ttl" mapstructure:"max_lease_ttl"` - ForceNoCache bool `json:"force_no_cache" mapstructure:"force_no_cache"` - AuditNonHMACRequestKeys []string `json:"audit_non_hmac_request_keys,omitempty" mapstructure:"audit_non_hmac_request_keys"` - AuditNonHMACResponseKeys []string `json:"audit_non_hmac_response_keys,omitempty" mapstructure:"audit_non_hmac_response_keys"` - ListingVisibility string `json:"listing_visibility,omitempty" mapstructure:"listing_visibility"` - PassthroughRequestHeaders []string `json:"passthrough_request_headers,omitempty" mapstructure:"passthrough_request_headers"` - AllowedResponseHeaders []string `json:"allowed_response_headers,omitempty" mapstructure:"allowed_response_headers"` - TokenType string `json:"token_type,omitempty" mapstructure:"token_type"` - AllowedManagedKeys []string `json:"allowed_managed_keys,omitempty" mapstructure:"allowed_managed_keys"` - + DefaultLeaseTTL int `json:"default_lease_ttl" mapstructure:"default_lease_ttl"` + MaxLeaseTTL int `json:"max_lease_ttl" mapstructure:"max_lease_ttl"` + ForceNoCache bool `json:"force_no_cache" mapstructure:"force_no_cache"` + AuditNonHMACRequestKeys []string `json:"audit_non_hmac_request_keys,omitempty" mapstructure:"audit_non_hmac_request_keys"` + AuditNonHMACResponseKeys []string `json:"audit_non_hmac_response_keys,omitempty" mapstructure:"audit_non_hmac_response_keys"` + ListingVisibility string `json:"listing_visibility,omitempty" mapstructure:"listing_visibility"` + PassthroughRequestHeaders []string `json:"passthrough_request_headers,omitempty" mapstructure:"passthrough_request_headers"` + AllowedResponseHeaders []string `json:"allowed_response_headers,omitempty" mapstructure:"allowed_response_headers"` + TokenType string `json:"token_type,omitempty" mapstructure:"token_type"` + AllowedManagedKeys []string `json:"allowed_managed_keys,omitempty" mapstructure:"allowed_managed_keys"` + UserLockoutConfig *UserLockoutConfigOutput `json:"user_lockout_config,omitempty"` // Deprecated: This field will always be blank for newer server responses. PluginName string `json:"plugin_name,omitempty" mapstructure:"plugin_name"` } +type UserLockoutConfigInput struct { + LockoutThreshold string `json:"lockout_threshold,omitempty" structs:"lockout_threshold" mapstructure:"lockout_threshold"` + LockoutDuration string `json:"lockout_duration,omitempty" structs:"lockout_duration" mapstructure:"lockout_duration"` + LockoutCounterResetDuration string `json:"lockout_counter_reset_duration,omitempty" structs:"lockout_counter_reset_duration" mapstructure:"lockout_counter_reset_duration"` + DisableLockout *bool `json:"lockout_disable,omitempty" structs:"lockout_disable" mapstructure:"lockout_disable"` +} + +type UserLockoutConfigOutput struct { + LockoutThreshold uint `json:"lockout_threshold,omitempty" structs:"lockout_threshold" mapstructure:"lockout_threshold"` + LockoutDuration int `json:"lockout_duration,omitempty" structs:"lockout_duration" mapstructure:"lockout_duration"` + LockoutCounterReset int `json:"lockout_counter_reset,omitempty" structs:"lockout_counter_reset" mapstructure:"lockout_counter_reset"` + DisableLockout *bool `json:"disable_lockout,omitempty" structs:"disable_lockout" mapstructure:"disable_lockout"` +} + type MountMigrationOutput struct { MigrationID string `mapstructure:"migration_id"` } diff --git a/changelog/17338.txt b/changelog/17338.txt new file mode 100644 index 000000000000..78ebec874619 --- /dev/null +++ b/changelog/17338.txt @@ -0,0 +1,3 @@ +```release-note:feature +core: Add user lockout field to config and configuring this for auth mount using auth tune to prevent brute forcing in auth methods +``` \ No newline at end of file diff --git a/command/auth_tune.go b/command/auth_tune.go index de4c19827314..6e3d3e7bce8f 100644 --- a/command/auth_tune.go +++ b/command/auth_tune.go @@ -20,18 +20,22 @@ var ( type AuthTuneCommand struct { *BaseCommand - flagAuditNonHMACRequestKeys []string - flagAuditNonHMACResponseKeys []string - flagDefaultLeaseTTL time.Duration - flagDescription string - flagListingVisibility string - flagMaxLeaseTTL time.Duration - flagPassthroughRequestHeaders []string - flagAllowedResponseHeaders []string - flagOptions map[string]string - flagTokenType string - flagVersion int - flagPluginVersion string + flagAuditNonHMACRequestKeys []string + flagAuditNonHMACResponseKeys []string + flagDefaultLeaseTTL time.Duration + flagDescription string + flagListingVisibility string + flagMaxLeaseTTL time.Duration + flagPassthroughRequestHeaders []string + flagAllowedResponseHeaders []string + flagOptions map[string]string + flagTokenType string + flagVersion int + flagPluginVersion string + flagUserLockoutThreshold uint + flagUserLockoutDuration time.Duration + flagUserLockoutCounterResetDuration time.Duration + flagUserLockoutDisable bool } func (c *AuthTuneCommand) Synopsis() string { @@ -145,6 +149,41 @@ func (c *AuthTuneCommand) Flags() *FlagSets { Usage: "Select the version of the auth method to run. Not supported by all auth methods.", }) + f.UintVar(&UintVar{ + Name: flagNameUserLockoutThreshold, + Target: &c.flagUserLockoutThreshold, + Usage: "The threshold for user lockout for this auth method. If unspecified, this " + + "defaults to the Vault server's globally configured user lockout threshold, " + + "or a previously configured value for the auth method.", + }) + + f.DurationVar(&DurationVar{ + Name: flagNameUserLockoutDuration, + Target: &c.flagUserLockoutDuration, + Completion: complete.PredictAnything, + Usage: "The user lockout duration for this auth method. If unspecified, this " + + "defaults to the Vault server's globally configured user lockout duration, " + + "or a previously configured value for the auth method.", + }) + + f.DurationVar(&DurationVar{ + Name: flagNameUserLockoutCounterResetDuration, + Target: &c.flagUserLockoutCounterResetDuration, + Completion: complete.PredictAnything, + Usage: "The user lockout counter reset duration for this auth method. If unspecified, this " + + "defaults to the Vault server's globally configured user lockout counter reset duration, " + + "or a previously configured value for the auth method.", + }) + + f.BoolVar(&BoolVar{ + Name: flagNameUserLockoutDisable, + Target: &c.flagUserLockoutDisable, + Default: false, + Usage: "Disable user lockout for this auth method. If unspecified, this " + + "defaults to the Vault server's globally configured user lockout disable, " + + "or a previously configured value for the auth method.", + }) + f.StringVar(&StringVar{ Name: flagNamePluginVersion, Target: &c.flagPluginVersion, @@ -230,6 +269,24 @@ func (c *AuthTuneCommand) Run(args []string) int { if fl.Name == flagNameTokenType { mountConfigInput.TokenType = c.flagTokenType } + switch fl.Name { + case flagNameUserLockoutThreshold, flagNameUserLockoutDuration, flagNameUserLockoutCounterResetDuration, flagNameUserLockoutDisable: + if mountConfigInput.UserLockoutConfig == nil { + mountConfigInput.UserLockoutConfig = &api.UserLockoutConfigInput{} + } + } + if fl.Name == flagNameUserLockoutThreshold { + mountConfigInput.UserLockoutConfig.LockoutThreshold = strconv.FormatUint(uint64(c.flagUserLockoutThreshold), 10) + } + if fl.Name == flagNameUserLockoutDuration { + mountConfigInput.UserLockoutConfig.LockoutDuration = ttlToAPI(c.flagUserLockoutDuration) + } + if fl.Name == flagNameUserLockoutCounterResetDuration { + mountConfigInput.UserLockoutConfig.LockoutCounterResetDuration = ttlToAPI(c.flagUserLockoutCounterResetDuration) + } + if fl.Name == flagNameUserLockoutDisable { + mountConfigInput.UserLockoutConfig.DisableLockout = &c.flagUserLockoutDisable + } if fl.Name == flagNamePluginVersion { mountConfigInput.PluginVersion = c.flagPluginVersion diff --git a/command/commands.go b/command/commands.go index 726042503455..9a64191d2965 100644 --- a/command/commands.go +++ b/command/commands.go @@ -126,6 +126,14 @@ const ( flagNameAllowedManagedKeys = "allowed-managed-keys" // flagNamePluginVersion selects what version of a plugin should be used. flagNamePluginVersion = "plugin-version" + // flagNameUserLockoutThreshold is the flag name used for tuning the auth mount lockout threshold parameter + flagNameUserLockoutThreshold = "user-lockout-threshold" + // flagNameUserLockoutDuration is the flag name used for tuning the auth mount lockout duration parameter + flagNameUserLockoutDuration = "user-lockout-duration" + // flagNameUserLockoutCounterResetDuration is the flag name used for tuning the auth mount lockout counter reset parameter + flagNameUserLockoutCounterResetDuration = "user-lockout-counter-reset-duration" + // flagNameUserLockoutDisable is the flag name used for tuning the auth mount disable lockout parameter + flagNameUserLockoutDisable = "user-lockout-disable" // flagNameDisableRedirects is used to prevent the client from honoring a single redirect as a response to a request flagNameDisableRedirects = "disable-redirects" ) diff --git a/command/server/config_test.go b/command/server/config_test.go index 073d52a7f4d1..21ebd38b63c1 100644 --- a/command/server/config_test.go +++ b/command/server/config_test.go @@ -36,6 +36,10 @@ func TestParseListeners(t *testing.T) { testParseListeners(t) } +func TestParseUserLockouts(t *testing.T) { + testParseUserLockouts(t) +} + func TestParseSockaddrTemplate(t *testing.T) { testParseSockaddrTemplate(t) } diff --git a/command/server/config_test_helpers.go b/command/server/config_test_helpers.go index bb6e273a6a46..248040f9cf8b 100644 --- a/command/server/config_test_helpers.go +++ b/command/server/config_test_helpers.go @@ -3,6 +3,7 @@ package server import ( "fmt" "reflect" + "sort" "strings" "testing" "time" @@ -892,6 +893,67 @@ listener "tcp" { } } +func testParseUserLockouts(t *testing.T) { + obj, _ := hcl.Parse(strings.TrimSpace(` + user_lockout "all" { + lockout_duration = "40m" + lockout_counter_reset = "45m" + disable_lockout = "false" + } + user_lockout "userpass" { + lockout_threshold = "100" + lockout_duration = "20m" + } + user_lockout "ldap" { + disable_lockout = "true" + }`)) + + config := Config{ + SharedConfig: &configutil.SharedConfig{}, + } + list, _ := obj.Node.(*ast.ObjectList) + objList := list.Filter("user_lockout") + configutil.ParseUserLockouts(config.SharedConfig, objList) + + sort.Slice(config.SharedConfig.UserLockouts[:], func(i, j int) bool { + return config.SharedConfig.UserLockouts[i].Type < config.SharedConfig.UserLockouts[j].Type + }) + + expected := &Config{ + SharedConfig: &configutil.SharedConfig{ + UserLockouts: []*configutil.UserLockout{ + { + Type: "all", + LockoutThreshold: 5, + LockoutDuration: 2400000000000, + LockoutCounterReset: 2700000000000, + DisableLockout: false, + }, + { + Type: "userpass", + LockoutThreshold: 100, + LockoutDuration: 1200000000000, + LockoutCounterReset: 2700000000000, + DisableLockout: false, + }, + { + Type: "ldap", + LockoutThreshold: 5, + LockoutDuration: 2400000000000, + LockoutCounterReset: 2700000000000, + DisableLockout: true, + }, + }, + }, + } + + sort.Slice(expected.SharedConfig.UserLockouts[:], func(i, j int) bool { + return expected.SharedConfig.UserLockouts[i].Type < expected.SharedConfig.UserLockouts[j].Type + }) + config.Prune() + require.Equal(t, config, *expected) +} + func testParseSockaddrTemplate(t *testing.T) { config, err := ParseConfig(` api_addr = < 0 { + result.found("user_lockout", "UserLockout") + if err := ParseUserLockouts(&result, o); err != nil { + return nil, fmt.Errorf("error parsing 'user_lockout': %w", err) + } + } + if o := list.Filter("telemetry"); len(o.Items) > 0 { result.found("telemetry", "Telemetry") if err := parseTelemetry(&result, o); err != nil { @@ -194,6 +203,22 @@ func (c *SharedConfig) Sanitized() map[string]interface{} { result["listeners"] = sanitizedListeners } + // Sanitize user lockout stanza + if len(c.UserLockouts) != 0 { + var sanitizedUserLockouts []interface{} + for _, userlockout := range c.UserLockouts { + cleanUserLockout := map[string]interface{}{ + "type": userlockout.Type, + "lockout_threshold": userlockout.LockoutThreshold, + "lockout_duration": userlockout.LockoutDuration, + "lockout_counter_reset": userlockout.LockoutCounterReset, + "disable_lockout": userlockout.DisableLockout, + } + sanitizedUserLockouts = append(sanitizedUserLockouts, cleanUserLockout) + } + result["user_lockout_configs"] = sanitizedUserLockouts + } + // Sanitize seals stanza if len(c.Seals) != 0 { var sanitizedSeals []interface{} diff --git a/internalshared/configutil/merge.go b/internalshared/configutil/merge.go index 8ae99ca4879d..fda6238e2493 100644 --- a/internalshared/configutil/merge.go +++ b/internalshared/configutil/merge.go @@ -14,6 +14,13 @@ func (c *SharedConfig) Merge(c2 *SharedConfig) *SharedConfig { result.Listeners = append(result.Listeners, l) } + for _, userlockout := range c.UserLockouts { + result.UserLockouts = append(result.UserLockouts, userlockout) + } + for _, userlockout := range c2.UserLockouts { + result.UserLockouts = append(result.UserLockouts, userlockout) + } + result.HCPLinkConf = c.HCPLinkConf if c2.HCPLinkConf != nil { result.HCPLinkConf = c2.HCPLinkConf diff --git a/internalshared/configutil/userlockout.go b/internalshared/configutil/userlockout.go new file mode 100644 index 000000000000..f2be4461b32d --- /dev/null +++ b/internalshared/configutil/userlockout.go @@ -0,0 +1,186 @@ +package configutil + +import ( + "errors" + "fmt" + "strconv" + "strings" + "time" + + "github.com/hashicorp/go-multierror" + "github.com/hashicorp/go-secure-stdlib/parseutil" + "github.com/hashicorp/hcl" + "github.com/hashicorp/hcl/hcl/ast" +) + +const ( + UserLockoutThresholdDefault = 5 + UserLockoutDurationDefault = 15 * time.Minute + UserLockoutCounterResetDefault = 15 * time.Minute + DisableUserLockoutDefault = false +) + +type UserLockout struct { + Type string + LockoutThreshold uint64 `hcl:"-"` + LockoutThresholdRaw interface{} `hcl:"lockout_threshold"` + LockoutDuration time.Duration `hcl:"-"` + LockoutDurationRaw interface{} `hcl:"lockout_duration"` + LockoutCounterReset time.Duration `hcl:"-"` + LockoutCounterResetRaw interface{} `hcl:"lockout_counter_reset"` + DisableLockout bool `hcl:"-"` + DisableLockoutRaw interface{} `hcl:"disable_lockout"` +} + +func ParseUserLockouts(result *SharedConfig, list *ast.ObjectList) error { + var err error + result.UserLockouts = make([]*UserLockout, 0, len(list.Items)) + userLockoutsMap := make(map[string]*UserLockout) + for i, item := range list.Items { + var userLockoutConfig UserLockout + if err := hcl.DecodeObject(&userLockoutConfig, item.Val); err != nil { + return multierror.Prefix(err, fmt.Sprintf("userLockouts.%d:", i)) + } + + // Base values + { + switch { + case userLockoutConfig.Type != "": + case len(item.Keys) == 1: + userLockoutConfig.Type = strings.ToLower(item.Keys[0].Token.Value().(string)) + default: + return multierror.Prefix(errors.New("auth type for user lockout must be specified, if it applies to all auth methods specify \"all\" "), fmt.Sprintf("user_lockouts.%d:", i)) + } + + userLockoutConfig.Type = strings.ToLower(userLockoutConfig.Type) + // Supported auth methods for user lockout configuration: ldap, approle, userpass + // "all" is used to apply the configuration to all supported auth methods + switch userLockoutConfig.Type { + case "all", "ldap", "approle", "userpass": + result.found(userLockoutConfig.Type, userLockoutConfig.Type) + default: + return multierror.Prefix(fmt.Errorf("unsupported auth type %q", userLockoutConfig.Type), fmt.Sprintf("user_lockouts.%d:", i)) + } + } + + // Lockout Parameters + + // Not setting raw entries to nil here as soon as they are parsed + // as they are used to set the missing user lockout configuration values later. + { + if userLockoutConfig.LockoutThresholdRaw != nil { + userLockoutThresholdString := fmt.Sprintf("%v", userLockoutConfig.LockoutThresholdRaw) + if userLockoutConfig.LockoutThreshold, err = strconv.ParseUint(userLockoutThresholdString, 10, 64); err != nil { + return multierror.Prefix(fmt.Errorf("error parsing lockout_threshold: %w", err), fmt.Sprintf("user_lockouts.%d", i)) + } + } + + if userLockoutConfig.LockoutDurationRaw != nil { + if userLockoutConfig.LockoutDuration, err = parseutil.ParseDurationSecond(userLockoutConfig.LockoutDurationRaw); err != nil { + return multierror.Prefix(fmt.Errorf("error parsing lockout_duration: %w", err), fmt.Sprintf("user_lockouts.%d", i)) + } + if userLockoutConfig.LockoutDuration < 0 { + return multierror.Prefix(errors.New("lockout_duration cannot be negative"), fmt.Sprintf("user_lockouts.%d", i)) + } + + } + + if userLockoutConfig.LockoutCounterResetRaw != nil { + if userLockoutConfig.LockoutCounterReset, err = parseutil.ParseDurationSecond(userLockoutConfig.LockoutCounterResetRaw); err != nil { + return multierror.Prefix(fmt.Errorf("error parsing lockout_counter_reset: %w", err), fmt.Sprintf("user_lockouts.%d", i)) + } + if userLockoutConfig.LockoutCounterReset < 0 { + return multierror.Prefix(errors.New("lockout_counter_reset cannot be negative"), fmt.Sprintf("user_lockouts.%d", i)) + } + + } + if userLockoutConfig.DisableLockoutRaw != nil { + if userLockoutConfig.DisableLockout, err = parseutil.ParseBool(userLockoutConfig.DisableLockoutRaw); err != nil { + return multierror.Prefix(fmt.Errorf("invalid value for disable_lockout: %w", err), fmt.Sprintf("user_lockouts.%d", i)) + } + } + } + userLockoutsMap[userLockoutConfig.Type] = &userLockoutConfig + } + + // Use raw entries to set values for user lockout configurations fields + // that were not configured using config file. + // The raw entries would mean that the entry was configured by the user using the config file. + // If any of these fields are not configured using the config file (missing fields), + // we set values for these fields with defaults + // The issue with not being able to use non-raw entries is because of fields lockout threshold + // and disable lockout. We cannot differentiate using non-raw entries if the user configured these fields + // with values (0 and false) or if the the user did not configure these values in config file at all. + // The raw fields are set to nil after setting missing values in setNilValuesForRawUserLockoutFields function + userLockoutsMap = setMissingUserLockoutValuesInMap(userLockoutsMap) + for _, userLockoutValues := range userLockoutsMap { + result.UserLockouts = append(result.UserLockouts, userLockoutValues) + } + return nil +} + +// setUserLockoutValueAllInMap sets default user lockout values for key "all" (all auth methods) +// for user lockout fields that are not configured using config file +func setUserLockoutValueAllInMap(userLockoutAll *UserLockout) *UserLockout { + if userLockoutAll.Type == "" { + userLockoutAll.Type = "all" + } + if userLockoutAll.LockoutThresholdRaw == nil { + userLockoutAll.LockoutThreshold = UserLockoutThresholdDefault + } + if userLockoutAll.LockoutDurationRaw == nil { + userLockoutAll.LockoutDuration = UserLockoutDurationDefault + } + if userLockoutAll.LockoutCounterResetRaw == nil { + userLockoutAll.LockoutCounterReset = UserLockoutCounterResetDefault + } + if userLockoutAll.DisableLockoutRaw == nil { + userLockoutAll.DisableLockout = DisableUserLockoutDefault + } + return setNilValuesForRawUserLockoutFields(userLockoutAll) +} + +// setDefaultUserLockoutValuesInMap sets missing user lockout fields for auth methods +// with default values (from key "all") that are not configured using config file +func setMissingUserLockoutValuesInMap(userLockoutsMap map[string]*UserLockout) map[string]*UserLockout { + // set values for "all" key with default values for "all" user lockout fields that are not configured + // the "all" key values will be used as default values for other auth methods + userLockoutAll, ok := userLockoutsMap["all"] + switch ok { + case true: + userLockoutsMap["all"] = setUserLockoutValueAllInMap(userLockoutAll) + default: + userLockoutsMap["all"] = setUserLockoutValueAllInMap(&UserLockout{}) + } + + for _, userLockoutAuth := range userLockoutsMap { + if userLockoutAuth.Type == "all" { + continue + } + // set missing values + if userLockoutAuth.LockoutThresholdRaw == nil { + userLockoutAuth.LockoutThreshold = userLockoutsMap["all"].LockoutThreshold + } + if userLockoutAuth.LockoutDurationRaw == nil { + userLockoutAuth.LockoutDuration = userLockoutsMap["all"].LockoutDuration + } + if userLockoutAuth.LockoutCounterResetRaw == nil { + userLockoutAuth.LockoutCounterReset = userLockoutsMap["all"].LockoutCounterReset + } + if userLockoutAuth.DisableLockoutRaw == nil { + userLockoutAuth.DisableLockout = userLockoutsMap["all"].DisableLockout + } + userLockoutAuth = setNilValuesForRawUserLockoutFields(userLockoutAuth) + userLockoutsMap[userLockoutAuth.Type] = userLockoutAuth + } + return userLockoutsMap +} + +// setNilValuesForRawUserLockoutFields sets nil values for user lockout Raw fields +func setNilValuesForRawUserLockoutFields(userLockout *UserLockout) *UserLockout { + userLockout.LockoutThresholdRaw = nil + userLockout.LockoutDurationRaw = nil + userLockout.LockoutCounterResetRaw = nil + userLockout.DisableLockoutRaw = nil + return userLockout +} diff --git a/internalshared/configutil/userlockout_test.go b/internalshared/configutil/userlockout_test.go new file mode 100644 index 000000000000..d5ab42cbe86a --- /dev/null +++ b/internalshared/configutil/userlockout_test.go @@ -0,0 +1,69 @@ +package configutil + +import ( + "reflect" + "testing" + "time" +) + +func TestParseUserLockout(t *testing.T) { + t.Parallel() + t.Run("Missing user lockout block in config file", func(t *testing.T) { + t.Parallel() + inputConfig := make(map[string]*UserLockout) + expectedConfig := make(map[string]*UserLockout) + expectedConfigall := &UserLockout{} + expectedConfigall.Type = "all" + expectedConfigall.LockoutThreshold = UserLockoutThresholdDefault + expectedConfigall.LockoutDuration = UserLockoutDurationDefault + expectedConfigall.LockoutCounterReset = UserLockoutCounterResetDefault + expectedConfigall.DisableLockout = DisableUserLockoutDefault + expectedConfig["all"] = expectedConfigall + + outputConfig := setMissingUserLockoutValuesInMap(inputConfig) + if !reflect.DeepEqual(expectedConfig["all"], outputConfig["all"]) { + t.Errorf("user lockout config: expected %#v\nactual %#v", expectedConfig["all"], outputConfig["all"]) + } + }) + t.Run("setting default lockout counter reset and lockout duration for userpass in config ", func(t *testing.T) { + t.Parallel() + // input user lockout in config file + inputConfig := make(map[string]*UserLockout) + configAll := &UserLockout{} + configAll.Type = "all" + configAll.LockoutCounterReset = 20 * time.Minute + configAll.LockoutCounterResetRaw = "1200000000000" + inputConfig["all"] = configAll + configUserpass := &UserLockout{} + configUserpass.Type = "userpass" + configUserpass.LockoutDuration = 10 * time.Minute + configUserpass.LockoutDurationRaw = "600000000000" + inputConfig["userpass"] = configUserpass + + expectedConfig := make(map[string]*UserLockout) + expectedConfigall := &UserLockout{} + expectedConfigUserpass := &UserLockout{} + // expected default values + expectedConfigall.Type = "all" + expectedConfigall.LockoutThreshold = UserLockoutThresholdDefault + expectedConfigall.LockoutDuration = UserLockoutDurationDefault + expectedConfigall.LockoutCounterReset = 20 * time.Minute + expectedConfigall.DisableLockout = DisableUserLockoutDefault + // expected values for userpass + expectedConfigUserpass.Type = "userpass" + expectedConfigUserpass.LockoutThreshold = UserLockoutThresholdDefault + expectedConfigUserpass.LockoutDuration = 10 * time.Minute + expectedConfigUserpass.LockoutCounterReset = 20 * time.Minute + expectedConfigUserpass.DisableLockout = DisableUserLockoutDefault + expectedConfig["all"] = expectedConfigall + expectedConfig["userpass"] = expectedConfigUserpass + + outputConfig := setMissingUserLockoutValuesInMap(inputConfig) + if !reflect.DeepEqual(expectedConfig["all"], outputConfig["all"]) { + t.Errorf("user lockout config: expected %#v\nactual %#v", expectedConfig["all"], outputConfig["all"]) + } + if !reflect.DeepEqual(expectedConfig["userpass"], outputConfig["userpass"]) { + t.Errorf("user lockout config: expected %#v\nactual %#v", expectedConfig["userpass"], outputConfig["userpass"]) + } + }) +} diff --git a/sdk/helper/consts/consts.go b/sdk/helper/consts/consts.go index c431e2e59419..a4b7c5040422 100644 --- a/sdk/helper/consts/consts.go +++ b/sdk/helper/consts/consts.go @@ -34,4 +34,6 @@ const ( ReplicationResolverALPN = "replication_resolver_v1" VaultEnableFilePermissionsCheckEnv = "VAULT_ENABLE_FILE_PERMISSIONS_CHECK" + + VaultDisableUserLockout = "VAULT_DISABLE_USER_LOCKOUT" ) diff --git a/vault/logical_system.go b/vault/logical_system.go index 1d1b7de8ba58..a6e6e418833f 100644 --- a/vault/logical_system.go +++ b/vault/logical_system.go @@ -944,6 +944,15 @@ func (b *SystemBackend) mountInfo(ctx context.Context, entry *MountEntry) map[st if entry.Table == credentialTableType { entryConfig["token_type"] = entry.Config.TokenType.String() } + if entry.Config.UserLockoutConfig != nil { + userLockoutConfig := map[string]interface{}{ + "user_lockout_counter_reset_duration": int64(entry.Config.UserLockoutConfig.LockoutCounterReset.Seconds()), + "user_lockout_threshold": entry.Config.UserLockoutConfig.LockoutThreshold, + "user_lockout_duration": int64(entry.Config.UserLockoutConfig.LockoutDuration.Seconds()), + "user_lockout_disable": entry.Config.UserLockoutConfig.DisableLockout, + } + entryConfig["user_lockout_config"] = userLockoutConfig + } // Add deprecation status only if it exists builtinType := b.Core.builtinTypeFromMountEntry(ctx, entry) @@ -1604,6 +1613,13 @@ func (b *SystemBackend) handleTuneReadCommon(ctx context.Context, path string) ( resp.Data["allowed_managed_keys"] = rawVal.([]string) } + if mountEntry.Config.UserLockoutConfig != nil { + resp.Data["user_lockout_counter_reset_duration"] = int64(mountEntry.Config.UserLockoutConfig.LockoutCounterReset.Seconds()) + resp.Data["user_lockout_threshold"] = mountEntry.Config.UserLockoutConfig.LockoutThreshold + resp.Data["user_lockout_duration"] = int64(mountEntry.Config.UserLockoutConfig.LockoutDuration.Seconds()) + resp.Data["user_lockout_disable"] = mountEntry.Config.UserLockoutConfig.DisableLockout + } + if len(mountEntry.Options) > 0 { resp.Data["options"] = mountEntry.Options } @@ -1723,6 +1739,111 @@ func (b *SystemBackend) handleTuneWriteCommon(ctx context.Context, path string, } } + // user-lockout config + { + var apiuserLockoutConfig APIUserLockoutConfig + + userLockoutConfigMap := data.Get("user_lockout_config").(map[string]interface{}) + var err error + if userLockoutConfigMap != nil && len(userLockoutConfigMap) != 0 { + err := mapstructure.Decode(userLockoutConfigMap, &apiuserLockoutConfig) + if err != nil { + return logical.ErrorResponse( + "unable to convert given user lockout config information"), + logical.ErrInvalidRequest + } + + // Supported auth methods for user lockout configuration: ldap, approle, userpass + switch strings.ToLower(mountEntry.Type) { + case "ldap", "approle", "userpass": + default: + return logical.ErrorResponse("tuning of user lockout configuration for auth type %q not allowed", mountEntry.Type), + logical.ErrInvalidRequest + + } + } + + if len(userLockoutConfigMap) > 0 && mountEntry.Config.UserLockoutConfig == nil { + mountEntry.Config.UserLockoutConfig = &UserLockoutConfig{} + } + + var oldUserLockoutThreshold uint64 + var newUserLockoutDuration, oldUserLockoutDuration time.Duration + var newUserLockoutCounterReset, oldUserLockoutCounterReset time.Duration + var oldUserLockoutDisable bool + + if apiuserLockoutConfig.LockoutThreshold != "" { + userLockoutThreshold, err := strconv.ParseUint(apiuserLockoutConfig.LockoutThreshold, 10, 64) + if err != nil { + return nil, fmt.Errorf("unable to parse user lockout threshold: %w", err) + } + oldUserLockoutThreshold = mountEntry.Config.UserLockoutConfig.LockoutThreshold + mountEntry.Config.UserLockoutConfig.LockoutThreshold = userLockoutThreshold + } + + if apiuserLockoutConfig.LockoutDuration != "" { + oldUserLockoutDuration = mountEntry.Config.UserLockoutConfig.LockoutDuration + switch apiuserLockoutConfig.LockoutDuration { + case "": + newUserLockoutDuration = oldUserLockoutDuration + case "system": + newUserLockoutDuration = time.Duration(0) + default: + tmpUserLockoutDuration, err := parseutil.ParseDurationSecond(apiuserLockoutConfig.LockoutDuration) + if err != nil { + return handleError(err) + } + newUserLockoutDuration = tmpUserLockoutDuration + + } + mountEntry.Config.UserLockoutConfig.LockoutDuration = newUserLockoutDuration + } + + if apiuserLockoutConfig.LockoutCounterResetDuration != "" { + oldUserLockoutCounterReset = mountEntry.Config.UserLockoutConfig.LockoutCounterReset + switch apiuserLockoutConfig.LockoutCounterResetDuration { + case "": + newUserLockoutCounterReset = oldUserLockoutCounterReset + case "system": + newUserLockoutCounterReset = time.Duration(0) + default: + tmpUserLockoutCounterReset, err := parseutil.ParseDurationSecond(apiuserLockoutConfig.LockoutCounterResetDuration) + if err != nil { + return handleError(err) + } + newUserLockoutCounterReset = tmpUserLockoutCounterReset + } + + mountEntry.Config.UserLockoutConfig.LockoutCounterReset = newUserLockoutCounterReset + } + + if apiuserLockoutConfig.DisableLockout != nil { + oldUserLockoutDisable = mountEntry.Config.UserLockoutConfig.DisableLockout + userLockoutDisable := apiuserLockoutConfig.DisableLockout + mountEntry.Config.UserLockoutConfig.DisableLockout = *userLockoutDisable + } + + // Update the mount table + if len(userLockoutConfigMap) > 0 { + switch { + case strings.HasPrefix(path, "auth/"): + err = b.Core.persistAuth(ctx, b.Core.auth, &mountEntry.Local) + default: + err = b.Core.persistMounts(ctx, b.Core.mounts, &mountEntry.Local) + } + if err != nil { + mountEntry.Config.UserLockoutConfig.LockoutCounterReset = oldUserLockoutCounterReset + mountEntry.Config.UserLockoutConfig.LockoutThreshold = oldUserLockoutThreshold + mountEntry.Config.UserLockoutConfig.LockoutDuration = oldUserLockoutDuration + mountEntry.Config.UserLockoutConfig.DisableLockout = oldUserLockoutDisable + return handleError(err) + } + if b.Core.logger.IsInfo() { + b.Core.logger.Info("tuning of user_lockout_config successful", "path", path) + } + } + + } if rawVal, ok := data.GetOk("description"); ok { description := rawVal.(string) @@ -5021,6 +5142,10 @@ in the plugin catalog.`, `The options to pass into the backend. Should be a json object with string keys and values.`, }, + "tune_user_lockout_config": { + `The user lockout configuration to pass into the backend. Should be a json object with string keys and values.`, + }, + "remount": { "Move the mount point of an already-mounted backend, within or across namespaces", ` diff --git a/vault/logical_system_paths.go b/vault/logical_system_paths.go index afd4343f22dc..be774673f989 100644 --- a/vault/logical_system_paths.go +++ b/vault/logical_system_paths.go @@ -1542,6 +1542,10 @@ func (b *SystemBackend) authPaths() []*framework.Path { Type: framework.TypeString, Description: strings.TrimSpace(sysHelp["token_type"][0]), }, + "user_lockout_config": { + Type: framework.TypeMap, + Description: strings.TrimSpace(sysHelp["tune_user_lockout_config"][0]), + }, "plugin_version": { Type: framework.TypeString, Description: strings.TrimSpace(sysHelp["plugin-catalog_version"][0]), @@ -1929,6 +1933,10 @@ func (b *SystemBackend) mountPaths() []*framework.Path { Type: framework.TypeString, Description: strings.TrimSpace(sysHelp["plugin-catalog_version"][0]), }, + "user_lockout_config": { + Type: framework.TypeMap, + Description: strings.TrimSpace(sysHelp["tune_user_lockout_config"][0]), + }, }, Callbacks: map[logical.Operation]framework.OperationFunc{ diff --git a/vault/mount.go b/vault/mount.go index b6813b0ad57d..5167ffa9cd7a 100644 --- a/vault/mount.go +++ b/vault/mount.go @@ -351,6 +351,7 @@ type MountConfig struct { AllowedResponseHeaders []string `json:"allowed_response_headers,omitempty" structs:"allowed_response_headers" mapstructure:"allowed_response_headers"` TokenType logical.TokenType `json:"token_type,omitempty" structs:"token_type" mapstructure:"token_type"` AllowedManagedKeys []string `json:"allowed_managed_keys,omitempty" mapstructure:"allowed_managed_keys"` + UserLockoutConfig *UserLockoutConfig `json:"user_lockout_config,omitempty" mapstructure:"user_lockout_config"` // PluginName is the name of the plugin registered in the catalog. // @@ -358,6 +359,20 @@ type MountConfig struct { PluginName string `json:"plugin_name,omitempty" structs:"plugin_name,omitempty" mapstructure:"plugin_name"` } +type UserLockoutConfig struct { + LockoutThreshold uint64 `json:"lockout_threshold,omitempty" structs:"lockout_threshold" mapstructure:"lockout_threshold"` + LockoutDuration time.Duration `json:"lockout_duration,omitempty" structs:"lockout_duration" mapstructure:"lockout_duration"` + LockoutCounterReset time.Duration `json:"lockout_counter_reset,omitempty" structs:"lockout_counter_reset" mapstructure:"lockout_counter_reset"` + DisableLockout bool `json:"disable_lockout,omitempty" structs:"disable_lockout" mapstructure:"disable_lockout"` +} + +type APIUserLockoutConfig struct { + LockoutThreshold string `json:"lockout_threshold,omitempty" structs:"lockout_threshold" mapstructure:"lockout_threshold"` + LockoutDuration string `json:"lockout_duration,omitempty" structs:"lockout_duration" mapstructure:"lockout_duration"` + LockoutCounterResetDuration string `json:"lockout_counter_reset_duration,omitempty" structs:"lockout_counter_reset_duration" mapstructure:"lockout_counter_reset_duration"` + DisableLockout *bool `json:"lockout_disable,omitempty" structs:"lockout_disable" mapstructure:"lockout_disable"` +} + // APIMountConfig is an embedded struct of api.MountConfigInput type APIMountConfig struct { DefaultLeaseTTL string `json:"default_lease_ttl" structs:"default_lease_ttl" mapstructure:"default_lease_ttl"` @@ -370,6 +385,7 @@ type APIMountConfig struct { AllowedResponseHeaders []string `json:"allowed_response_headers,omitempty" structs:"allowed_response_headers" mapstructure:"allowed_response_headers"` TokenType string `json:"token_type" structs:"token_type" mapstructure:"token_type"` AllowedManagedKeys []string `json:"allowed_managed_keys,omitempty" mapstructure:"allowed_managed_keys"` + UserLockoutConfig *UserLockoutConfig `json:"user_lockout_config,omitempty" mapstructure:"user_lockout_config"` PluginVersion string `json:"plugin_version,omitempty" mapstructure:"plugin_version"` // PluginName is the name of the plugin registered in the catalog. @@ -1275,8 +1291,8 @@ func (c *Core) runMountUpdates(ctx context.Context, needPersist bool) error { entry.NamespaceID = namespace.RootNamespaceID needPersist = true } - } + } // Done if we have restored the mount table and we don't need // to persist if !needPersist {