Skip to content

Commit 731bef9

Browse files
committed
feat: add login throttling to prevent brute force attacks
Implements in-memory login attempt throttling that tracks failed attempts per identity and temporarily locks accounts after exceeding configurable thresholds. New config options under selfservice.flows.login.throttle: - max_attempts: max failures before lockout (default: 0 = disabled) - window: time window for counting failures (default: 5m) - lockout_duration: how long account stays locked (default: 15m) When throttling is enabled and an identity exceeds max_attempts within the window, subsequent login attempts return HTTP 429 until the lockout expires. Successful logins reset the counter. Relates to #3037
1 parent 7710d46 commit 731bef9

File tree

6 files changed

+392
-2
lines changed

6 files changed

+392
-2
lines changed

driver/config/config.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,9 @@ const (
127127
ViperKeySelfServiceLoginRequestLifespan = "selfservice.flows.login.lifespan"
128128
ViperKeySelfServiceLoginAfter = "selfservice.flows.login.after"
129129
ViperKeySelfServiceLoginBeforeHooks = "selfservice.flows.login.before.hooks"
130+
ViperKeySelfServiceLoginThrottleMaxAttempts = "selfservice.flows.login.throttle.max_attempts"
131+
ViperKeySelfServiceLoginThrottleWindow = "selfservice.flows.login.throttle.window"
132+
ViperKeySelfServiceLoginThrottleLockoutDuration = "selfservice.flows.login.throttle.lockout_duration"
130133
ViperKeySelfServiceErrorUI = "selfservice.flows.error.ui_url"
131134
ViperKeySelfServiceLogoutBrowserDefaultReturnTo = "selfservice.flows.logout.after." + DefaultBrowserReturnURL
132135
ViperKeySelfServiceSettingsURL = "selfservice.flows.settings.ui_url"
@@ -1039,6 +1042,24 @@ func (p *Config) SelfServiceFlowLoginRequestLifespan(ctx context.Context) time.D
10391042
return p.GetProvider(ctx).DurationF(ViperKeySelfServiceLoginRequestLifespan, time.Hour)
10401043
}
10411044

1045+
// SelfServiceFlowLoginThrottleMaxAttempts returns the maximum number of
1046+
// failed login attempts before temporary lockout.
1047+
func (p *Config) SelfServiceFlowLoginThrottleMaxAttempts(ctx context.Context) int {
1048+
return p.GetProvider(ctx).IntF(ViperKeySelfServiceLoginThrottleMaxAttempts, 0)
1049+
}
1050+
1051+
// SelfServiceFlowLoginThrottleWindow returns the window in which
1052+
// failed login attempts are counted.
1053+
func (p *Config) SelfServiceFlowLoginThrottleWindow(ctx context.Context) time.Duration {
1054+
return p.GetProvider(ctx).DurationF(ViperKeySelfServiceLoginThrottleWindow, 5*time.Minute)
1055+
}
1056+
1057+
// SelfServiceFlowLoginThrottleLockoutDuration returns how long an identity
1058+
// is locked out after exceeding the max attempts.
1059+
func (p *Config) SelfServiceFlowLoginThrottleLockoutDuration(ctx context.Context) time.Duration {
1060+
return p.GetProvider(ctx).DurationF(ViperKeySelfServiceLoginThrottleLockoutDuration, 15*time.Minute)
1061+
}
1062+
10421063
func (p *Config) SelfServiceFlowSettingsFlowLifespan(ctx context.Context) time.Duration {
10431064
return p.GetProvider(ctx).DurationF(ViperKeySelfServiceSettingsRequestLifespan, time.Hour)
10441065
}

selfservice/flow/login/error.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,15 @@ var (
4343

4444
// ErrSessionRequiredForHigherAAL is returned when someone requests AAL2 or AAL3 even though no active session exists yet.
4545
ErrSessionRequiredForHigherAAL = herodot.ErrUnauthorized.WithID(text.ErrIDSessionRequiredForHigherAAL).WithError("aal2 and aal3 can only be requested if a session exists already").WithReason("You can not requested a higher AAL (AAL2/AAL3) without an active session.")
46+
47+
// ErrAccountLockedOut is returned when an identity has been temporarily
48+
// locked due to too many failed login attempts.
49+
ErrAccountLockedOut = herodot.DefaultError{
50+
StatusField: "Too Many Requests",
51+
ErrorField: "account temporarily locked",
52+
ReasonField: "Too many failed login attempts. Please try again later.",
53+
CodeField: http.StatusTooManyRequests,
54+
}
4655
)
4756

4857
type (

selfservice/flow/login/handler.go

Lines changed: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
package login
55

66
import (
7+
"context"
78
"net/http"
89
"strconv"
910
"time"
@@ -20,6 +21,7 @@ import (
2021
"github.com/ory/kratos/identity"
2122
"github.com/ory/kratos/schema"
2223
"github.com/ory/kratos/selfservice/errorx"
24+
"github.com/ory/kratos/selfservice/flow/login/throttle"
2325
"github.com/ory/kratos/selfservice/flow"
2426
"github.com/ory/kratos/selfservice/sessiontokenexchange"
2527
"github.com/ory/kratos/session"
@@ -70,12 +72,32 @@ type (
7072
HandlerProvider interface {
7173
LoginHandler() *Handler
7274
}
73-
Handler struct{ d dependencies }
75+
Handler struct {
76+
d dependencies
77+
throttler *throttle.Limiter
78+
}
7479
)
7580

76-
func NewHandler(d dependencies) *Handler { return &Handler{d: d} }
81+
func NewHandler(d dependencies) *Handler {
82+
return &Handler{d: d}
83+
}
84+
85+
func (h *Handler) initThrottler(ctx context.Context) {
86+
maxAttempts := h.d.Config().SelfServiceFlowLoginThrottleMaxAttempts(ctx)
87+
if maxAttempts <= 0 {
88+
return
89+
}
90+
h.throttler = throttle.NewLimiter(throttle.Config{
91+
MaxAttempts: maxAttempts,
92+
ThrottleWindow: h.d.Config().SelfServiceFlowLoginThrottleWindow(ctx),
93+
LockoutDuration: h.d.Config().SelfServiceFlowLoginThrottleLockoutDuration(ctx),
94+
})
95+
}
7796

7897
func (h *Handler) RegisterPublicRoutes(public *httprouterx.RouterPublic) {
98+
// Initialize login throttler if configured.
99+
h.initThrottler(context.Background())
100+
79101
h.d.CSRFHandler().IgnorePath(RouteInitAPIFlow)
80102
h.d.CSRFHandler().IgnorePath(RouteSubmitFlow)
81103

@@ -932,10 +954,27 @@ continueLogin:
932954
} else if errors.Is(err, flow.ErrCompletedByStrategy) {
933955
return
934956
} else if err != nil {
957+
// Record failed login attempt for throttling.
958+
if h.throttler != nil {
959+
if identityID := x.ExtractIdentityID(err); identityID != uuid.Nil {
960+
if locked := h.throttler.RecordFailure(identityID); locked {
961+
h.d.LoginFlowErrorHandler().WriteFlowError(w, r, f, ss.ID(), group, errors.WithStack(ErrAccountLockedOut))
962+
return
963+
}
964+
}
965+
}
935966
h.d.LoginFlowErrorHandler().WriteFlowError(w, r, f, ss.ID(), group, err)
936967
return
937968
}
938969

970+
// Check if identity is locked out before allowing login.
971+
if h.throttler != nil {
972+
if locked, _ := h.throttler.IsLockedOut(interim.ID); locked {
973+
h.d.LoginFlowErrorHandler().WriteFlowError(w, r, f, ss.ID(), group, errors.WithStack(ErrAccountLockedOut))
974+
return
975+
}
976+
}
977+
939978
// What can happen is that we re-authenticate as another user. In this case, we need to use a completely fresh
940979
// session!
941980
if sess.IdentityID != uuid.Nil && sess.IdentityID != interim.ID {
@@ -954,6 +993,11 @@ continueLogin:
954993
return
955994
}
956995

996+
// Clear throttle state on successful login.
997+
if h.throttler != nil {
998+
h.throttler.RecordSuccess(i.ID)
999+
}
1000+
9571001
if err := h.d.LoginHookExecutor().PostLoginHook(w, r, group, f, i, sess, ""); err != nil {
9581002
if errors.Is(err, ErrAddressNotVerified) {
9591003
h.d.LoginFlowErrorHandler().WriteFlowError(w, r, f, ct, node.DefaultGroup, errors.WithStack(schema.NewAddressNotVerifiedError()))
Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
// Copyright © 2023 Ory Corp
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package throttle
5+
6+
import (
7+
"sync"
8+
"time"
9+
10+
"github.com/gofrs/uuid"
11+
)
12+
13+
// Config holds the configuration for login throttling.
14+
type Config struct {
15+
// MaxAttempts is the maximum number of failed login attempts
16+
// before the identity gets locked out temporarily.
17+
MaxAttempts int
18+
19+
// ThrottleWindow is the time window within which failed attempts are counted.
20+
ThrottleWindow time.Duration
21+
22+
// LockoutDuration is how long an identity is locked out after
23+
// exceeding MaxAttempts within the ThrottleWindow.
24+
LockoutDuration time.Duration
25+
}
26+
27+
// DefaultConfig returns the default throttle configuration.
28+
func DefaultConfig() Config {
29+
return Config{
30+
MaxAttempts: 5,
31+
ThrottleWindow: 5 * time.Minute,
32+
LockoutDuration: 15 * time.Minute,
33+
}
34+
}
35+
36+
type attempt struct {
37+
timestamp time.Time
38+
}
39+
40+
type identityState struct {
41+
attempts []attempt
42+
lockedAt time.Time
43+
lockUntil time.Time
44+
}
45+
46+
// Limiter tracks failed login attempts per identity and enforces throttling.
47+
type Limiter struct {
48+
mu sync.RWMutex
49+
states map[uuid.UUID]*identityState
50+
config Config
51+
}
52+
53+
// NewLimiter creates a new login throttle limiter.
54+
func NewLimiter(cfg Config) *Limiter {
55+
l := &Limiter{
56+
states: make(map[uuid.UUID]*identityState),
57+
config: cfg,
58+
}
59+
return l
60+
}
61+
62+
// IsLockedOut checks whether the given identity is currently locked out.
63+
// Returns true and the remaining lockout duration if locked out.
64+
func (l *Limiter) IsLockedOut(identityID uuid.UUID) (bool, time.Duration) {
65+
l.mu.RLock()
66+
defer l.mu.RUnlock()
67+
68+
state, ok := l.states[identityID]
69+
if !ok {
70+
return false, 0
71+
}
72+
73+
if !state.lockUntil.IsZero() && time.Now().Before(state.lockUntil) {
74+
return true, time.Until(state.lockUntil)
75+
}
76+
77+
return false, 0
78+
}
79+
80+
// RecordFailure records a failed login attempt for the given identity.
81+
// Returns true if the identity is now locked out as a result.
82+
func (l *Limiter) RecordFailure(identityID uuid.UUID) bool {
83+
l.mu.Lock()
84+
defer l.mu.Unlock()
85+
86+
state, ok := l.states[identityID]
87+
if !ok {
88+
state = &identityState{}
89+
l.states[identityID] = state
90+
}
91+
92+
// If currently locked, don't reset anything
93+
if !state.lockUntil.IsZero() && time.Now().Before(state.lockUntil) {
94+
return true
95+
}
96+
97+
// Prune old attempts outside the throttle window
98+
now := time.Now()
99+
cutoff := now.Add(-l.config.ThrottleWindow)
100+
pruned := make([]attempt, 0, len(state.attempts))
101+
for _, a := range state.attempts {
102+
if a.timestamp.After(cutoff) {
103+
pruned = append(pruned, a)
104+
}
105+
}
106+
107+
// Add the new failure
108+
pruned = append(pruned, attempt{timestamp: now})
109+
state.attempts = pruned
110+
111+
// Check if we should lock out
112+
if len(state.attempts) >= l.config.MaxAttempts {
113+
state.lockedAt = now
114+
state.lockUntil = now.Add(l.config.LockoutDuration)
115+
return true
116+
}
117+
118+
return false
119+
}
120+
121+
// RecordSuccess clears the failed attempt history for the given identity.
122+
func (l *Limiter) RecordSuccess(identityID uuid.UUID) {
123+
l.mu.Lock()
124+
defer l.mu.Unlock()
125+
126+
delete(l.states, identityID)
127+
}
128+
129+
// RecentFailures returns the number of recent failed attempts for the identity
130+
// within the throttle window.
131+
func (l *Limiter) RecentFailures(identityID uuid.UUID) int {
132+
l.mu.RLock()
133+
defer l.mu.RUnlock()
134+
135+
state, ok := l.states[identityID]
136+
if !ok {
137+
return 0
138+
}
139+
140+
cutoff := time.Now().Add(-l.config.ThrottleWindow)
141+
count := 0
142+
for _, a := range state.attempts {
143+
if a.timestamp.After(cutoff) {
144+
count++
145+
}
146+
}
147+
return count
148+
}
149+
150+
// Cleanup removes expired entries. Should be called periodically
151+
// to prevent unbounded memory growth.
152+
func (l *Limiter) Cleanup() {
153+
l.mu.Lock()
154+
defer l.mu.Unlock()
155+
156+
now := time.Now()
157+
for id, state := range l.states {
158+
// Remove entries where lockout has expired and no recent attempts
159+
if !state.lockUntil.IsZero() && now.After(state.lockUntil) {
160+
delete(l.states, id)
161+
continue
162+
}
163+
164+
// Remove entries with no recent attempts in the window
165+
cutoff := now.Add(-l.config.ThrottleWindow)
166+
hasRecent := false
167+
for _, a := range state.attempts {
168+
if a.timestamp.After(cutoff) {
169+
hasRecent = true
170+
break
171+
}
172+
}
173+
if !hasRecent && state.lockUntil.IsZero() {
174+
delete(l.states, id)
175+
}
176+
}
177+
}

0 commit comments

Comments
 (0)