aboutsummaryrefslogtreecommitdiff
path: root/internal/game/login_throttle.go
blob: b8191886ffe08b2ea1aa599c549da03e2f1ec1c8 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
package game

import (
	"sync"
	"time"
)

// Interactive login throttling. The admin HTTP /login route has its own
// limiter; the telnet/TLS/websocket login is the primary credential surface
// and needs one too. The policy is an escalating delay keyed on
// (account, client IP): a hard lockout is deliberately avoided because
// account existence is discoverable and a lockout would be a targeted
// griefing tool. The delay runs on the connection goroutine, never the
// owner, so a brute-force attempt stalls only its own connection.

const (
	// loginThrottleMaxDelay caps the escalation so a legitimate player who
	// fat-fingers a password a few times is inconvenienced, not locked out.
	loginThrottleMaxDelay = 4 * time.Second
	// loginThrottleMaxKeys bounds the tracked keys so a source that cycles
	// account names or addresses cannot grow server memory without bound.
	loginThrottleMaxKeys = 4096
	// loginThrottleEntryTTL is how long an untouched entry is retained.
	loginThrottleEntryTTL = 10 * time.Minute
)

type loginThrottleEntry struct {
	failures int
	lastSeen time.Time
}

type loginThrottle struct {
	mu      sync.Mutex
	entries map[string]loginThrottleEntry
}

func newLoginThrottle() *loginThrottle {
	return &loginThrottle{entries: make(map[string]loginThrottleEntry)}
}

// ensureLoginThrottle returns the game's login throttle, creating it on first
// use. The connection goroutine calls this; sync.Once makes the lazy init
// race-free for Game literals that did not go through New.
func (g *Game) ensureLoginThrottle() *loginThrottle {
	g.loginThrottleOnce.Do(func() {
		g.loginThrottle = newLoginThrottle()
	})
	return g.loginThrottle
}

// loginThrottleKey combines the attempted account and the client address.
// Either may be empty (synthetic connections).
func loginThrottleKey(account, ip string) string {
	return account + "\x00" + ip
}

// delayFor records a failed attempt and returns the escalating delay the
// caller should wait before responding: 0, 250ms, 500ms, 1s, 2s, 4s, 4s...
func (t *loginThrottle) delayFor(key string) time.Duration {
	t.mu.Lock()
	defer t.mu.Unlock()
	now := time.Now()
	if len(t.entries) >= loginThrottleMaxKeys {
		t.evictLocked(now)
	}
	e := t.entries[key]
	e.failures++
	e.lastSeen = now
	t.entries[key] = e
	if e.failures < 2 {
		// The first wrong password is free: a single typo must not cost a
		// legitimate player any time.
		return 0
	}
	shift := e.failures - 2
	if shift > 4 { // 250ms << 4 == loginThrottleMaxDelay
		shift = 4
	}
	d := time.Duration(250*int64(1<<uint(shift))) * time.Millisecond
	if d > loginThrottleMaxDelay {
		d = loginThrottleMaxDelay
	}
	return d
}

// clear forgets a key after a successful login.
func (t *loginThrottle) clear(key string) {
	t.mu.Lock()
	delete(t.entries, key)
	t.mu.Unlock()
}

// evictLocked drops expired entries, then the oldest entry if still at the
// cap. Caller holds mu.
func (t *loginThrottle) evictLocked(now time.Time) {
	for k, e := range t.entries {
		if now.Sub(e.lastSeen) > loginThrottleEntryTTL {
			delete(t.entries, k)
		}
	}
	if len(t.entries) < loginThrottleMaxKeys {
		return
	}
	oldestKey := ""
	var oldest time.Time
	for k, e := range t.entries {
		if oldestKey == "" || e.lastSeen.Before(oldest) {
			oldestKey, oldest = k, e.lastSeen
		}
	}
	if oldestKey != "" {
		delete(t.entries, oldestKey)
	}
}