Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

VAULT-5422: Add rate limit for TOTP passcode attempts #14864

Merged
merged 12 commits into from
Apr 14, 2022
3 changes: 3 additions & 0 deletions changelog/14864.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
```release-note:improvement
Login MFA: enforce a rate limit for TOTP passcode validation attempts
hghaf099 marked this conversation as resolved.
Show resolved Hide resolved
```
16 changes: 16 additions & 0 deletions command/server/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,9 @@ type Config struct {
LogRequestsLevel string `hcl:"-"`
LogRequestsLevelRaw interface{} `hcl:"log_requests_level"`

MaximumTOTPValidationAttempts int64 `hcl:"-"`
hghaf099 marked this conversation as resolved.
Show resolved Hide resolved
MaximumTOTPValidationAttemptsRaw interface{} `hcl:"max_totp_validation_attempts"`

EnableResponseHeaderRaftNodeID bool `hcl:"-"`
EnableResponseHeaderRaftNodeIDRaw interface{} `hcl:"enable_response_header_raft_node_id"`

Expand Down Expand Up @@ -301,6 +304,11 @@ func (c *Config) Merge(c2 *Config) *Config {
result.LogRequestsLevel = c2.LogRequestsLevel
}

result.MaximumTOTPValidationAttempts = c.MaximumTOTPValidationAttempts
if c2.MaximumTOTPValidationAttempts != 0 {
result.MaximumTOTPValidationAttempts = c2.MaximumTOTPValidationAttempts
}

result.EnableResponseHeaderRaftNodeID = c.EnableResponseHeaderRaftNodeID
if c2.EnableResponseHeaderRaftNodeID {
result.EnableResponseHeaderRaftNodeID = c2.EnableResponseHeaderRaftNodeID
Expand Down Expand Up @@ -494,6 +502,14 @@ func ParseConfig(d, source string) (*Config, error) {
result.LogRequestsLevelRaw = ""
}

if result.MaximumTOTPValidationAttemptsRaw != nil {
if result.MaximumTOTPValidationAttempts, err = parseutil.ParseInt(result.MaximumTOTPValidationAttemptsRaw); err != nil {
return nil, err
}

result.MaximumTOTPValidationAttemptsRaw = nil
}

if result.EnableResponseHeaderRaftNodeIDRaw != nil {
if result.EnableResponseHeaderRaftNodeID, err = parseutil.ParseBool(result.EnableResponseHeaderRaftNodeIDRaw); err != nil {
return nil, err
Expand Down
6 changes: 4 additions & 2 deletions command/server/config_test_helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -449,6 +449,8 @@ func testLoadConfigFile(t *testing.T) {
EnableUI: true,
EnableUIRaw: true,

MaximumTOTPValidationAttempts: 10,

EnableRawEndpoint: true,
EnableRawEndpointRaw: true,

Expand Down Expand Up @@ -487,8 +489,8 @@ func testUnknownFieldValidation(t *testing.T) {
Problem: "unknown or unsupported field bad_value found in configuration",
Position: token.Pos{
Filename: "./test-fixtures/config.hcl",
Offset: 583,
Line: 34,
Offset: 617,
Line: 35,
Column: 5,
},
},
Expand Down
1 change: 1 addition & 0 deletions command/server/test-fixtures/config.hcl
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ disable_cache = true
disable_mlock = true

ui = true
max_totp_validation_attempts = 10

listener "tcp" {
address = "127.0.0.1:443"
Expand Down
10 changes: 9 additions & 1 deletion vault/core.go
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,10 @@ const (
// MfaAuthResponse when the value is not specified in the server config
defaultMFAAuthResponseTTL = 300 * time.Second

// defaultMaxTOTPValidateAttempts is the default value for the number
// of failed attempts to validate a request subject to TOTP MFA
hghaf099 marked this conversation as resolved.
Show resolved Hide resolved
defaultMaxTOTPValidateAttempts = 5

// ForwardSSCTokenToActive is the value that must be set in the
// forwardToActive to trigger forwarding if a perf standby encounters
// an SSC Token that it does not have the WAL state for.
Expand Down Expand Up @@ -1004,7 +1008,11 @@ func NewCore(conf *CoreConfig) (*Core, error) {
c.ha = conf.HAPhysical
}

c.loginMFABackend = NewLoginMFABackend(c, conf.Logger)
maxTOTPValidationAttempts := conf.RawConfig.MaximumTOTPValidationAttempts
if maxTOTPValidationAttempts == 0 {
maxTOTPValidationAttempts = defaultMaxTOTPValidateAttempts
}
c.loginMFABackend = NewLoginMFABackend(c, conf.Logger, maxTOTPValidationAttempts)

logicalBackends := make(map[string]logical.Factory)
for k, f := range conf.LogicalBackends {
Expand Down
24 changes: 24 additions & 0 deletions vault/external_tests/identity/login_mfa_totp_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,30 @@ func TestLoginMfaGenerateTOTPTestAuditIncluded(t *testing.T) {
t.Fatalf("expected error message to mention code already used")
}

// check for reaching max failed validation requests
secret, err = user2Client.Logical().WriteWithContext(context.Background(), "auth/userpass/login/testuser", map[string]interface{}{
"password": "testpassword",
})
if err != nil {
t.Fatalf("MFA failed: %v", err)
}

var maxErr error
for i := 0; i < 6; i++ {
_, maxErr = user2Client.Logical().WriteWithContext(context.Background(), "sys/mfa/validate", map[string]interface{}{
"mfa_request_id": secret.Auth.MFARequirement.MFARequestID,
"mfa_payload": map[string][]string{
methodID: {fmt.Sprintf("%d", i)},
},
})
if maxErr == nil {
t.Fatalf("MFA succeeded with an invalid passcode")
}
}
if !strings.Contains(maxErr.Error(), "maximum TOTP validation attempts 6 exceeded 5") {
t.Fatalf("unexpected error message when exceeding max failed validation attempts")
}

// Destroy the secret so that the token can self generate
_, err = userClient.Logical().WriteWithContext(context.Background(), fmt.Sprintf("identity/mfa/method/totp/admin-destroy"), map[string]interface{}{
"entity_id": entityID,
Expand Down
63 changes: 43 additions & 20 deletions vault/login_mfa.go
Original file line number Diff line number Diff line change
Expand Up @@ -96,13 +96,14 @@ func genericOptionalUUIDRegex(name string) string {
}

type MFABackend struct {
Core *Core
mfaLock *sync.RWMutex
db *memdb.MemDB
mfaLogger hclog.Logger
namespacer Namespacer
methodTable string
usedCodes *cache.Cache
Core *Core
mfaLock *sync.RWMutex
db *memdb.MemDB
mfaLogger hclog.Logger
namespacer Namespacer
methodTable string
usedCodes *cache.Cache
maximumTOTPValidationAttempts int64
}

type LoginMFABackend struct {
Expand All @@ -116,12 +117,12 @@ func loginMFASchemaFuncs() []func() *memdb.TableSchema {
}
}

func NewLoginMFABackend(core *Core, logger hclog.Logger) *LoginMFABackend {
b := NewMFABackend(core, logger, memDBLoginMFAConfigsTable, loginMFASchemaFuncs())
func NewLoginMFABackend(core *Core, logger hclog.Logger, maxTOTPValidationAttempts int64) *LoginMFABackend {
b := NewMFABackend(core, logger, memDBLoginMFAConfigsTable, loginMFASchemaFuncs(), maxTOTPValidationAttempts)
return &LoginMFABackend{b}
}

func NewMFABackend(core *Core, logger hclog.Logger, prefix string, schemaFuncs []func() *memdb.TableSchema) *MFABackend {
func NewMFABackend(core *Core, logger hclog.Logger, prefix string, schemaFuncs []func() *memdb.TableSchema, maxTOTPValidationAttempts int64) *MFABackend {
mfaSchemas := &memdb.DBSchema{
Tables: make(map[string]*memdb.TableSchema),
}
Expand All @@ -136,12 +137,13 @@ func NewMFABackend(core *Core, logger hclog.Logger, prefix string, schemaFuncs [

db, _ := memdb.NewMemDB(mfaSchemas)
return &MFABackend{
Core: core,
mfaLock: &sync.RWMutex{},
db: db,
mfaLogger: logger.Named("mfa"),
namespacer: core,
methodTable: prefix,
Core: core,
mfaLock: &sync.RWMutex{},
db: db,
mfaLogger: logger.Named("mfa"),
namespacer: core,
methodTable: prefix,
maximumTOTPValidationAttempts: maxTOTPValidationAttempts,
}
}

Expand Down Expand Up @@ -1425,7 +1427,7 @@ func (c *Core) validateLoginMFAInternal(ctx context.Context, methodID string, en
return fmt.Errorf("MFA credentials not supplied")
}

return c.validateTOTP(ctx, mfaCreds, entityMFASecret, mConfig.ID, entity.ID)
return c.validateTOTP(ctx, mfaCreds, entityMFASecret, mConfig.ID, entity.ID, c.loginMFABackend.usedCodes, c.loginMFABackend.maximumTOTPValidationAttempts)

case mfaMethodTypeOkta:
return c.validateOkta(ctx, mConfig, finalUsername)
Expand Down Expand Up @@ -1997,7 +1999,7 @@ func (c *Core) validatePingID(ctx context.Context, mConfig *mfa.Config, username
return nil
}

func (c *Core) validateTOTP(ctx context.Context, creds []string, entityMethodSecret *mfa.Secret, configID, entityID string) error {
func (c *Core) validateTOTP(ctx context.Context, creds []string, entityMethodSecret *mfa.Secret, configID, entityID string, usedCodes *cache.Cache, maximumValidationAttempts int64) error {
hghaf099 marked this conversation as resolved.
Show resolved Hide resolved
if len(creds) == 0 {
return fmt.Errorf("missing TOTP passcode")
}
Expand All @@ -2013,11 +2015,29 @@ func (c *Core) validateTOTP(ctx context.Context, creds []string, entityMethodSec

usedName := fmt.Sprintf("%s_%s", configID, creds[0])

_, ok := c.loginMFABackend.usedCodes.Get(usedName)
_, ok := usedCodes.Get(usedName)
raskchanky marked this conversation as resolved.
Show resolved Hide resolved
if ok {
return fmt.Errorf("code already used; new code is available in %v seconds", totpSecret.Period)
}

numAttempts, _ := usedCodes.Get(configID)
hghaf099 marked this conversation as resolved.
Show resolved Hide resolved
if numAttempts == nil {
usedCodes.Set(configID, int64(1), defaultMFAAuthResponseTTL)
hghaf099 marked this conversation as resolved.
Show resolved Hide resolved
} else {
err := usedCodes.Increment(configID, 1)
hghaf099 marked this conversation as resolved.
Show resolved Hide resolved
if err != nil {
return fmt.Errorf("failed to increment the TOTP code counter")
}
}
numAttempts, _ = usedCodes.Get(configID)
num, ok := numAttempts.(int64)
if !ok {
return fmt.Errorf("invalid counter type returned in TOTP usedCode cache")
}
if num > maximumValidationAttempts {
return fmt.Errorf("maximum TOTP validation attempts %d exceeded %d", num, maximumValidationAttempts)
}
hghaf099 marked this conversation as resolved.
Show resolved Hide resolved

key, err := c.fetchTOTPKey(ctx, configID, entityID)
if err != nil {
return errwrap.Wrapf("error fetching TOTP key: {{err}}", err)
Expand Down Expand Up @@ -2048,11 +2068,14 @@ func (c *Core) validateTOTP(ctx context.Context, creds []string, entityMethodSec
validityPeriod := time.Duration(int64(time.Second) * int64(totpSecret.Period) * int64(2+totpSecret.Skew))

// Adding the used code to the cache
err = c.loginMFABackend.usedCodes.Add(usedName, nil, validityPeriod)
err = usedCodes.Add(usedName, nil, validityPeriod)
if err != nil {
return fmt.Errorf("error adding code to used cache: %w", err)
}

// resetting the number of attempts to 0 after a successful validation
usedCodes.Set(configID, int64(0), defaultMFAAuthResponseTTL)

return nil
}

Expand Down
2 changes: 1 addition & 1 deletion website/content/docs/auth/login-mfa/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ MFA in Vault includes the following login types:
TOTP passcodes by default. We recommend that per-client [rate limits](/docs/concepts/resource-quotas)
are applied to the relevant login and/or mfa paths (e.g. `/sys/mfa/validate`). External MFA
methods (`Duo`, `Ping` and `Okta`) may already provide configurable rate limiting. Rate limiting of
Login MFA paths will be enforced by default in a future release.
Login MFA paths will be enforced by default from 10.1.0 release.
hghaf099 marked this conversation as resolved.
Show resolved Hide resolved

Login MFA can be configured to secure further authenticating to an auth method. To enable login
MFA, an MFA method needs to be configured. Please see [Login MFA API](/api-docs/secret/identity/mfa) for details
Expand Down