diff --git a/changelog/23140.txt b/changelog/23140.txt new file mode 100644 index 0000000000..c030090bbb --- /dev/null +++ b/changelog/23140.txt @@ -0,0 +1,3 @@ +```release-note:improvement +core: emit logs when user(s) are locked out and when all lockouts have been cleared +``` \ No newline at end of file diff --git a/command/server.go b/command/server.go index 0f52570dd6..6b91557ac2 100644 --- a/command/server.go +++ b/command/server.go @@ -3069,6 +3069,7 @@ func createCoreConfig(c *ServerCommand, config *server.Config, backend physical. DisableSSCTokens: config.DisableSSCTokens, Experiments: config.Experiments, AdministrativeNamespacePath: config.AdministrativeNamespacePath, + UserLockoutLogInterval: config.UserLockoutLogInterval, } if c.flagDev { diff --git a/command/server/config_test_helpers.go b/command/server/config_test_helpers.go index f7fe62ec9e..cb10655a7e 100644 --- a/command/server/config_test_helpers.go +++ b/command/server/config_test_helpers.go @@ -780,6 +780,7 @@ func testConfig_Sanitized(t *testing.T) { "enable_response_header_hostname": false, "enable_response_header_raft_node_id": false, "log_requests_level": "basic", + "user_lockout_log_interval": 0 * time.Second, "ha_storage": map[string]interface{}{ "cluster_addr": "top_level_cluster_addr", "disable_clustering": true, diff --git a/http/sys_config_state_test.go b/http/sys_config_state_test.go index fc55b948b0..40e4551017 100644 --- a/http/sys_config_state_test.go +++ b/http/sys_config_state_test.go @@ -167,6 +167,7 @@ func TestSysConfigState_Sanitized(t *testing.T) { "enable_response_header_hostname": false, "enable_response_header_raft_node_id": false, "log_requests_level": "", + "user_lockout_log_interval": json.Number("0"), "listeners": []interface{}{ map[string]interface{}{ "config": nil, diff --git a/internalshared/configutil/config.go b/internalshared/configutil/config.go index a662600f46..f5e8d6fa01 100644 --- a/internalshared/configutil/config.go +++ b/internalshared/configutil/config.go @@ -23,7 +23,9 @@ type SharedConfig struct { Listeners []*Listener `hcl:"-"` - UserLockouts []*UserLockout `hcl:"-"` + UserLockouts []*UserLockout `hcl:"-"` + UserLockoutLogInterval time.Duration `hcl:"-"` + UserLockoutLogIntervalRaw interface{} `hcl:"user_lockout_log_interval"` Seals []*KMS `hcl:"-"` Entropy *Entropy `hcl:"-"` @@ -87,6 +89,14 @@ func ParseConfig(d string) (*SharedConfig, error) { result.DisableMlockRaw = nil } + if result.UserLockoutLogIntervalRaw != nil { + if result.UserLockoutLogInterval, err = parseutil.ParseDurationSecond(result.UserLockoutLogIntervalRaw); err != nil { + return nil, err + } + result.FoundKeys = append(result.FoundKeys, "UserLockoutLogInterval") + result.UserLockoutLogIntervalRaw = nil + } + list, ok := obj.Node.(*ast.ObjectList) if !ok { return nil, fmt.Errorf("error parsing: file doesn't contain a root object") @@ -184,6 +194,7 @@ func (c *SharedConfig) Sanitized() map[string]interface{} { "pid_file": c.PidFile, "cluster_name": c.ClusterName, "administrative_namespace_path": c.AdministrativeNamespacePath, + "user_lockout_log_interval": c.UserLockoutLogInterval, } // Optional log related settings diff --git a/internalshared/configutil/merge.go b/internalshared/configutil/merge.go index 5068be556c..f75946393d 100644 --- a/internalshared/configutil/merge.go +++ b/internalshared/configutil/merge.go @@ -56,6 +56,11 @@ func (c *SharedConfig) Merge(c2 *SharedConfig) *SharedConfig { result.DefaultMaxRequestDuration = c2.DefaultMaxRequestDuration } + result.UserLockoutLogInterval = c.UserLockoutLogInterval + if c2.UserLockoutLogInterval > result.UserLockoutLogInterval { + result.UserLockoutLogInterval = c2.UserLockoutLogInterval + } + result.LogLevel = c.LogLevel if c2.LogLevel != "" { result.LogLevel = c2.LogLevel diff --git a/vault/core.go b/vault/core.go index 7c3d3c6f9f..7d3fd79b83 100644 --- a/vault/core.go +++ b/vault/core.go @@ -105,6 +105,11 @@ const ( // MfaAuthResponse when the value is not specified in the server config defaultMFAAuthResponseTTL = 300 * time.Second + // defaultUserLockoutLogInterval is the default duration that Vault will + // emit a log informing that a user lockout is in effect when the value + // is not specified in the server config + defaultUserLockoutLogInterval = 1 * time.Minute + // defaultMaxTOTPValidateAttempts is the default value for the number // of failed attempts to validate a request subject to TOTP MFA. If the // number of failed totp passcode validations exceeds this max value, the @@ -673,6 +678,9 @@ type Core struct { updateLockedUserEntriesCancel context.CancelFunc + lockoutLoggerCancel context.CancelFunc + userLockoutLogInterval time.Duration + // number of workers to use for lease revocation in the expiration manager numExpirationWorkers int @@ -888,6 +896,8 @@ type CoreConfig struct { AdministrativeNamespacePath string NumRollbackWorkers int + + UserLockoutLogInterval time.Duration } // SubloggerHook implements the SubloggerAdder interface. This implementation @@ -935,6 +945,10 @@ func CreateCore(conf *CoreConfig) (*Core, error) { return nil, fmt.Errorf("cannot have DefaultLeaseTTL larger than MaxLeaseTTL") } + if conf.UserLockoutLogInterval == 0 { + conf.UserLockoutLogInterval = defaultUserLockoutLogInterval + } + // Validate the advertise addr if its given to us if conf.RedirectAddr != "" { u, err := url.Parse(conf.RedirectAddr) @@ -1059,6 +1073,7 @@ func CreateCore(conf *CoreConfig) (*Core, error) { disableSSCTokens: conf.DisableSSCTokens, effectiveSDKVersion: effectiveSDKVersion, userFailedLoginInfo: make(map[FailedLoginUser]*FailedLoginInfo), + userLockoutLogInterval: conf.UserLockoutLogInterval, experiments: conf.Experiments, pendingRemovalMountsAllowed: conf.PendingRemovalMountsAllowed, expirationRevokeRetryBase: conf.ExpirationRevokeRetryBase, @@ -3615,6 +3630,51 @@ func (c *Core) setupCachedMFAResponseAuth() { return } +func (c *Core) startLockoutLogger() { + // Are we already running a logger + if c.lockoutLoggerCancel != nil { + return + } + + ctx, cancelFunc := context.WithCancel(c.activeContext) + c.lockoutLoggerCancel = cancelFunc + + // Perform first check for lockout entries + lockedUserCount := c.getUserFailedLoginCount(ctx) + + if lockedUserCount > 0 { + c.Logger().Warn("user lockout(s) in effect") + } else { + // We shouldn't end up here + return + } + + // Start lockout watcher + go func() { + ticker := time.NewTicker(c.userLockoutLogInterval) + for { + select { + case <-ticker.C: + // Check for lockout entries + lockedUserCount := c.getUserFailedLoginCount(ctx) + + if lockedUserCount > 0 { + c.Logger().Warn("user lockout(s) in effect") + break + } + c.Logger().Info("user lockout(s) cleared") + ticker.Stop() + c.lockoutLoggerCancel = nil + return + case <-ctx.Done(): + ticker.Stop() + c.lockoutLoggerCancel = nil + return + } + } + }() +} + // updateLockedUserEntries runs every 15 mins to remove stale user entries from storage // it also updates the userFailedLoginInfo map with correct information for locked users if incorrect func (c *Core) updateLockedUserEntries() { @@ -3643,7 +3703,13 @@ func (c *Core) updateLockedUserEntries() { } } }() - return +} + +func (c *Core) getUserFailedLoginCount(ctx context.Context) int { + c.userFailedLoginInfoLock.Lock() + defer c.userFailedLoginInfoLock.Unlock() + + return len(c.userFailedLoginInfo) } // runLockedUserEntryUpdates runs updates for locked user storage entries and userFailedLoginInfo map diff --git a/vault/logical_system_user_lockout.go b/vault/logical_system_user_lockout.go index 8390b70888..de2f653464 100644 --- a/vault/logical_system_user_lockout.go +++ b/vault/logical_system_user_lockout.go @@ -51,6 +51,16 @@ func unlockUser(ctx context.Context, core *Core, mountAccessor string, aliasName return err } + // Check if we have no more locked users and cancel any running lockout logger + core.userFailedLoginInfoLock.RLock() + numLockedUsers := len(core.userFailedLoginInfo) + core.userFailedLoginInfoLock.RUnlock() + + if numLockedUsers == 0 { + core.Logger().Info("user lockout(s) cleared") + core.lockoutLoggerCancel() + } + return nil } diff --git a/vault/request_handling.go b/vault/request_handling.go index b2e8da2f2a..6efa599105 100644 --- a/vault/request_handling.go +++ b/vault/request_handling.go @@ -1483,6 +1483,7 @@ func (c *Core) handleLoginRequest(ctx context.Context, req *logical.Request) (re return nil, nil, err } if isloginUserLocked { + c.startLockoutLogger() return nil, nil, logical.ErrPermissionDenied } } @@ -2298,6 +2299,8 @@ func (c *Core) LocalGetUserFailedLoginInfo(ctx context.Context, userKey FailedLo // LocalUpdateUserFailedLoginInfo updates the failed login information for a user based on alias name and mountAccessor func (c *Core) LocalUpdateUserFailedLoginInfo(ctx context.Context, userKey FailedLoginUser, failedLoginInfo *FailedLoginInfo, deleteEntry bool) error { c.userFailedLoginInfoLock.Lock() + defer c.userFailedLoginInfoLock.Unlock() + switch deleteEntry { case false: // update entry in the map @@ -2340,7 +2343,6 @@ func (c *Core) LocalUpdateUserFailedLoginInfo(ctx context.Context, userKey Faile // delete the entry from the map, if no key exists it is no-op delete(c.userFailedLoginInfo, userKey) } - c.userFailedLoginInfoLock.Unlock() return nil }