Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions internal/api/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,7 @@ func NewHandler(cfg HandlerConfig) *Handler {
"change_password",
"register",
"approve_cancel_public",
"mfa",
)

return h
Expand Down
8 changes: 4 additions & 4 deletions internal/api/handler_auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -552,7 +552,7 @@ func mapMFAServiceError(err error) error {
// the body (base64-encoded, same convention as login). Returns the
// generated secret + otpauth provisioning URI for QR display.
func (h *Handler) mfaSetup(ctx context.Context, req *events.LambdaFunctionURLRequest) (*MFASetupResponse, error) {
session, err := h.requireSession(ctx, req)
session, err := h.requireMFASession(ctx, req)
if err != nil {
return nil, err
}
Expand All @@ -576,7 +576,7 @@ func (h *Handler) mfaSetup(ctx context.Context, req *events.LambdaFunctionURLReq
// TOTP code against the pending secret + flips MFAEnabled=true on
// success. Returns the plaintext recovery codes once.
func (h *Handler) mfaEnable(ctx context.Context, req *events.LambdaFunctionURLRequest) (*MFAEnableResponse, error) {
session, err := h.requireSession(ctx, req)
session, err := h.requireMFASession(ctx, req)
if err != nil {
return nil, err
}
Expand All @@ -597,7 +597,7 @@ func (h *Handler) mfaEnable(ctx context.Context, req *events.LambdaFunctionURLRe
// code). On success: clears the secret + recovery codes, flips
// MFAEnabled=false.
func (h *Handler) mfaDisable(ctx context.Context, req *events.LambdaFunctionURLRequest) (any, error) {
session, err := h.requireSession(ctx, req)
session, err := h.requireMFASession(ctx, req)
if err != nil {
return nil, err
}
Expand All @@ -622,7 +622,7 @@ func (h *Handler) mfaDisable(ctx context.Context, req *events.LambdaFunctionURLR
// recovery codes with a fresh batch. Requires a current TOTP code
// (NOT a recovery code).
func (h *Handler) mfaRegenerateRecoveryCodes(ctx context.Context, req *events.LambdaFunctionURLRequest) (*MFARegenerateResponse, error) {
session, err := h.requireSession(ctx, req)
session, err := h.requireMFASession(ctx, req)
if err != nil {
return nil, err
}
Expand Down
85 changes: 85 additions & 0 deletions internal/api/handler_auth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1635,3 +1635,88 @@ func TestMapMFAServiceError_AuthFailed_Is401(t *testing.T) {
assert.Equal(t, 401, ce.code, "ErrMFAAuthFailed must map to HTTP 401")
assert.Contains(t, ce.Error(), auth.ErrMFAAuthFailed.Error())
}

// Issue #94: every MFA route that checks a password, TOTP or recovery code
// must be throttled per IP and per session user.
func TestHandler_mfaEndpoints_RateLimited(t *testing.T) {
limit := getDefaultRateLimits()["mfa"].MaxAttempts
invalid := auth.ErrMFAInvalidCode
cases := []struct {
name string
method string
args []any
ret []any
call func(h *Handler, req *events.LambdaFunctionURLRequest) error
body string
}{
{"setup", "MFASetupAPI", []any{"user-1", "pw"}, []any{"", "", invalid},
func(h *Handler, r *events.LambdaFunctionURLRequest) error {
_, err := h.mfaSetup(context.Background(), r)
return err
},
`{"password":"` + b64("pw") + `"}`},
{"enable", "MFAEnableAPI", []any{"user-1", "000000"}, []any{[]string(nil), invalid},
func(h *Handler, r *events.LambdaFunctionURLRequest) error {
_, err := h.mfaEnable(context.Background(), r)
return err
},
`{"code":"000000"}`},
{"disable", "MFADisableAPI", []any{"user-1", "pw", "000000"}, []any{invalid},
func(h *Handler, r *events.LambdaFunctionURLRequest) error {
_, err := h.mfaDisable(context.Background(), r)
return err
},
`{"password":"` + b64("pw") + `","code":"000000"}`},
{"regenerate", "MFARegenerateRecoveryCodesAPI", []any{"user-1", "000000"}, []any{[]string(nil), invalid},
func(h *Handler, r *events.LambdaFunctionURLRequest) error {
_, err := h.mfaRegenerateRecoveryCodes(context.Background(), r)
return err
},
`{"code":"000000"}`},
}
for _, tc := range cases {
for _, rotateIP := range []bool{false, true} {
t.Run(fmt.Sprintf("%s/rotateIP=%v", tc.name, rotateIP), func(t *testing.T) {
mockAuth := new(MockAuthService)
t.Cleanup(func() { mockAuth.AssertExpectations(t) })
mockAuth.On("ValidateSession", mock.Anything, "tok").
Return(&Session{UserID: "user-1", Email: "u@x.com"}, nil)
mockAuth.On(tc.method, append([]any{mock.Anything}, tc.args...)...).
Return(tc.ret...).Times(limit)
h := &Handler{auth: mockAuth, rateLimiter: NewInMemoryRateLimiter()}

for i := 0; i <= limit; i++ {
req := authedReq("tok", tc.body)
req.RequestContext.HTTP.SourceIP = "198.51.100.1"
if rotateIP {
req.RequestContext.HTTP.SourceIP = fmt.Sprintf("198.51.100.%d", i+1)
}
err := tc.call(h, req)
ce, ok := IsClientError(err)
require.True(t, ok, "attempt %d: %v", i+1, err)
if i < limit {
assert.Equal(t, 400, ce.code, "attempt %d", i+1)
} else {
assert.Equal(t, 429, ce.code, "attempt %d must be throttled", i+1)
}
}
})
}
}
}

func TestHandler_mfaEnable_RateLimiterErrorFailsClosed(t *testing.T) {
mockAuth := new(MockAuthService)
t.Cleanup(func() { mockAuth.AssertExpectations(t) })
mockAuth.On("ValidateSession", mock.Anything, "tok").Return(&Session{UserID: "user-1"}, nil)
rl := new(MockRateLimiter)
t.Cleanup(func() { rl.AssertExpectations(t) })
rl.On("AllowWithIP", mock.Anything, mock.Anything, "mfa").Return(true, nil)
rl.On("AllowWithUser", mock.Anything, "user-1", "mfa").Return(false, errors.New("db down"))
h := &Handler{auth: mockAuth, rateLimiter: rl}

_, err := h.mfaEnable(context.Background(), authedReq("tok", `{"code":"000000"}`))
ce, ok := IsClientError(err)
require.True(t, ok)
assert.Equal(t, 503, ce.code)
}
28 changes: 28 additions & 0 deletions internal/api/middleware.go
Original file line number Diff line number Diff line change
Expand Up @@ -569,3 +569,31 @@ func (h *Handler) checkRateLimitStrict(ctx context.Context, req *events.LambdaFu
}
return nil
}

// requireMFASession is requireSession plus the "mfa" bucket applied per IP and
// then per session user, both fail-closed, so a stolen session cannot reset its
// guessing budget by rotating IPs (#94).
func (h *Handler) requireMFASession(ctx context.Context, req *events.LambdaFunctionURLRequest) (*Session, error) {
const endpoint = "mfa"
if err := h.checkRateLimitStrict(ctx, req, endpoint); err != nil {
return nil, err
}
session, err := h.requireSession(ctx, req)
if err != nil {
return nil, err
}
if h.rateLimiter == nil {
return session, nil
}
allowed, err := h.rateLimiter.AllowWithUser(ctx, session.UserID, endpoint)
if err != nil {
logging.Errorf("ALERT: rate limiter error on credential endpoint %s for user %s; request denied (02-M1): %v",
endpoint, session.UserID, err)
return nil, NewClientError(503, "service temporarily unavailable, please try again later")
}
if !allowed {
logging.Warnf("Rate limit exceeded for %s for user: %s", endpoint, session.UserID)
return nil, NewClientError(429, "too many requests, please try again later")
}
return session, nil
}
4 changes: 4 additions & 0 deletions internal/api/rate_limiter.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,10 @@ func getDefaultRateLimits() map[string]RateLimitConfig {
// api_general (300/min), enabling bulk table-flooding and inbox-spam. Limit
// to 5 per 15 minutes per IP, matching setup_admin severity.
"register": NewRateLimitConfig(5, 15*60), // 5 attempts / 15 minutes / IP (#1016)
// mfa covers every /api/auth/mfa/* route that checks a password, TOTP or
// recovery code. Applied per IP AND per session user (#94), so rotating
// source IPs does not reset one account's guessing budget.
"mfa": NewRateLimitConfig(10, 15*60), // 10 attempts / 15 minutes / IP and / user (#94)
}
}

Expand Down
Loading