Skip to content

Commit

Permalink
home: imp authratelimiter
Browse files Browse the repository at this point in the history
  • Loading branch information
EugeneOne1 committed Apr 27, 2021
1 parent c73a407 commit ff4220d
Show file tree
Hide file tree
Showing 5 changed files with 76 additions and 84 deletions.
5 changes: 3 additions & 2 deletions internal/home/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -436,12 +436,13 @@ func handleLogin(w http.ResponseWriter, r *http.Request) {
if blocker := Context.auth.blocker; blocker != nil {
left := blocker.check(remoteAddr)
if left > 0 {
w.Header().Set("Retry-After", fmt.Sprint(int64(left.Seconds())))
leftMin, leftSec := int64(left.Minutes()), int64(left.Seconds())
w.Header().Set("Retry-After", fmt.Sprint(leftSec))
httpError(
w,
http.StatusTooManyRequests,
"auth: blocked for %d minute(s)",
0,
leftMin,
)

return
Expand Down
92 changes: 41 additions & 51 deletions internal/home/authratelimiter.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,55 +5,47 @@ import (
"time"
)

// flushAfter defines for how long will attempter be tracked.
const flushAfter = 1 * time.Minute
// failedAuthTTL is the period of time for which the failed attempt will stay in
// cache.
const failedAuthTTL = 1 * time.Minute

// attempter is an entry of authRateLimiter's cache.
type attempter struct {
// failedAuth is an entry of authRateLimiter's cache.
type failedAuth struct {
until time.Time
num uint16
}

// authRateLimiter used to cache unsuccessful authentication attempts.
// authRateLimiter used to cache failed authentication attempts.
type authRateLimiter struct {
attempters map[string]attempter
blockDur time.Duration
attemptersLock sync.RWMutex
maxAttempts uint16
failedAuths map[string]failedAuth
// failedAuthsLock protects failedAuths.
failedAuthsLock sync.Mutex
blockDur time.Duration
maxAttempts uint16
}

// newAuthRateLimiter returns properly initialized *authRateLimiter.
func newAuthRateLimiter(blockDur time.Duration, maxAttempts uint16) (ab *authRateLimiter) {
return &authRateLimiter{
attempters: make(map[string]attempter),
failedAuths: make(map[string]failedAuth),
blockDur: blockDur,
maxAttempts: maxAttempts,
}
}

// flushLocked checks each tracked attempter removing expired ones. For
// cleanupLocked checks each blocked users removing ones with expired TTL. For
// internal use only.
func (ab *authRateLimiter) flushLocked(now time.Time) {
for k, v := range ab.attempters {
func (ab *authRateLimiter) cleanupLocked(now time.Time) {
for k, v := range ab.failedAuths {
if now.After(v.until) {
delete(ab.attempters, k)
delete(ab.failedAuths, k)
}
}
}

// flush stops tracking of attempters tracking period of which have ended.
func (ab *authRateLimiter) flush() {
now := time.Now()

ab.attemptersLock.Lock()
defer ab.attemptersLock.Unlock()

ab.flushLocked(now)
}

// checkLocked checks the attempter for it's state. For internal use only.
func (ab *authRateLimiter) checkLocked(attID string, now time.Time) (left time.Duration) {
a, ok := ab.attempters[attID]
func (ab *authRateLimiter) checkLocked(usrID string, now time.Time) (left time.Duration) {
a, ok := ab.failedAuths[usrID]
if !ok {
return 0
}
Expand All @@ -67,53 +59,51 @@ func (ab *authRateLimiter) checkLocked(attID string, now time.Time) (left time.D

// check returns the time left until unblocking. The nonpositive result should
// be interpreted as not blocked attempter.
func (ab *authRateLimiter) check(attID string) (left time.Duration) {
func (ab *authRateLimiter) check(usrID string) (left time.Duration) {
now := time.Now()
ab.flush()

ab.attemptersLock.RLock()
defer ab.attemptersLock.RUnlock()
ab.failedAuthsLock.Lock()
defer ab.failedAuthsLock.Unlock()

return ab.checkLocked(attID, now)
ab.cleanupLocked(now)
return ab.checkLocked(usrID, now)
}

// incLocked increments the number of unsuccessful attempts for attempter with
// ip and updates it's blocking moment if needed. For internal use only.
func (ab *authRateLimiter) incLocked(attID string, now time.Time) {
var until time.Time = now.Add(flushAfter)
var attempts uint16 = 1
func (ab *authRateLimiter) incLocked(usrID string, now time.Time) {
var until time.Time = now.Add(failedAuthTTL)
var attNum uint16 = 1

a, ok := ab.attempters[attID]
a, ok := ab.failedAuths[usrID]
if ok {
// The attempter will be tracked during at least 1 minute since
// very first unsuccessful attempt but not since each one.
until = a.until
attempts = a.num + 1
attNum = a.num + 1
}
if attempts >= ab.maxAttempts {
if attNum >= ab.maxAttempts {
until = now.Add(ab.blockDur)
}

ab.attempters[attID] = attempter{
num: attempts,
ab.failedAuths[usrID] = failedAuth{
num: attNum,
until: until,
}
}

// inc updates the tracked attempter.
func (ab *authRateLimiter) inc(attID string) {
// inc updates the failed attempt in cache.
func (ab *authRateLimiter) inc(usrID string) {
now := time.Now()

ab.attemptersLock.Lock()
defer ab.attemptersLock.Unlock()
ab.failedAuthsLock.Lock()
defer ab.failedAuthsLock.Unlock()

ab.incLocked(attID, now)
ab.incLocked(usrID, now)
}

// remove stops any tracking and any blocking of an attempter.
func (ab *authRateLimiter) remove(attID string) {
ab.attemptersLock.Lock()
defer ab.attemptersLock.Unlock()
// remove stops any tracking and any blocking of the user.
func (ab *authRateLimiter) remove(usrID string) {
ab.failedAuthsLock.Lock()
defer ab.failedAuthsLock.Unlock()

delete(ab.attempters, attID)
delete(ab.failedAuths, usrID)
}
53 changes: 27 additions & 26 deletions internal/home/authratelimiter_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,50 +9,51 @@ import (
"github.com/stretchr/testify/require"
)

func TestAuthRateLimiter_Flush(t *testing.T) {
func TestAuthRateLimiter_Cleanup(t *testing.T) {
const key = "some-key"
now := time.Now()

testCases := []struct {
name string
att attempter
att failedAuth
wantExp bool
}{{
name: "expired",
att: attempter{
until: time.Now().Add(-100 * time.Hour),
att: failedAuth{
until: now.Add(-100 * time.Hour),
},
wantExp: true,
}, {
name: "nope_yet",
att: attempter{
until: time.Now().Add(flushAfter / 2),
att: failedAuth{
until: now.Add(failedAuthTTL / 2),
},
wantExp: false,
}, {
name: "blocked",
att: attempter{
until: time.Now().Add(100 * time.Hour),
att: failedAuth{
until: now.Add(100 * time.Hour),
},
wantExp: false,
}}

for _, tc := range testCases {
ab := &authRateLimiter{
attempters: map[string]attempter{
failedAuths: map[string]failedAuth{
key: tc.att,
},
}
t.Run(tc.name, func(t *testing.T) {
ab.flush()
ab.cleanupLocked(now)
if tc.wantExp {
assert.Empty(t, ab.attempters)
assert.Empty(t, ab.failedAuths)

return
}

require.Len(t, ab.attempters, 1)
require.Len(t, ab.failedAuths, 1)

_, ok := ab.attempters[key]
_, ok := ab.failedAuths[key]
require.True(t, ok)
})
}
Expand All @@ -74,7 +75,7 @@ func TestAuthRateLimiter_Check(t *testing.T) {
num: 0,
wantExp: true,
}, {
until: now.Add(flushAfter),
until: now.Add(failedAuthTTL),
name: "not_blocked_but_tracked",
num: 0,
wantExp: true,
Expand All @@ -91,15 +92,15 @@ func TestAuthRateLimiter_Check(t *testing.T) {
}}

for _, tc := range testCases {
attempters := map[string]attempter{
failedAuths := map[string]failedAuth{
key: {
num: tc.num,
until: tc.until,
},
}
ab := &authRateLimiter{
maxAttempts: maxAtt,
attempters: attempters,
failedAuths: failedAuths,
}
t.Run(tc.name, func(t *testing.T) {
until := ab.check(key)
Expand All @@ -114,7 +115,7 @@ func TestAuthRateLimiter_Check(t *testing.T) {

t.Run("non-existent", func(t *testing.T) {
ab := &authRateLimiter{
attempters: map[string]attempter{
failedAuths: map[string]failedAuth{
key + "smthng": {},
},
}
Expand Down Expand Up @@ -147,13 +148,13 @@ func TestAuthRateLimiter_Inc(t *testing.T) {
}, {
name: "inc_and_block",
until: now,
wantUntil: now.Add(flushAfter),
wantUntil: now.Add(failedAuthTTL),
num: maxAtt,
wantNum: maxAtt + 1,
}}

for _, tc := range testCases {
attempters := map[string]attempter{
failedAuths := map[string]failedAuth{
key: {
num: tc.num,
until: tc.until,
Expand All @@ -162,12 +163,12 @@ func TestAuthRateLimiter_Inc(t *testing.T) {
ab := &authRateLimiter{
blockDur: blockDur,
maxAttempts: maxAtt,
attempters: attempters,
failedAuths: failedAuths,
}
t.Run(tc.name, func(t *testing.T) {
ab.inc(key)

a, ok := ab.attempters[key]
a, ok := ab.failedAuths[key]
require.True(t, ok)

assert.Equal(t, tc.wantNum, a.num)
Expand All @@ -179,12 +180,12 @@ func TestAuthRateLimiter_Inc(t *testing.T) {
ab := &authRateLimiter{
blockDur: blockDur,
maxAttempts: maxAtt,
attempters: map[string]attempter{},
failedAuths: map[string]failedAuth{},
}

ab.inc(key)

a, ok := ab.attempters[key]
a, ok := ab.failedAuths[key]
require.True(t, ok)
assert.Equal(t, uint16(1), a.num)
})
Expand All @@ -193,14 +194,14 @@ func TestAuthRateLimiter_Inc(t *testing.T) {
func TestAuthRateLimiter_Remove(t *testing.T) {
const key = "some-key"

attempters := map[string]attempter{
failedAuths := map[string]failedAuth{
key: {},
}
ab := &authRateLimiter{
attempters: attempters,
failedAuths: failedAuths,
}

ab.remove(key)

assert.Empty(t, ab.attempters)
assert.Empty(t, ab.failedAuths)
}
8 changes: 4 additions & 4 deletions internal/home/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,11 +47,11 @@ type configuration struct {
BindPort int `yaml:"bind_port"` // BindPort is the port the HTTP server
BetaBindPort int `yaml:"beta_bind_port"` // BetaBindPort is the port for new client
Users []User `yaml:"users"` // Users that can access HTTP server
// AuthAttempts defines max attempts to authenticate before temporary
// block of the user.
// AuthAttempts is the maximum number of failed login attempts a user
// can do before being blocked.
AuthAttempts uint16 `yaml:"auth_attempts"`
// AuthBlockMin defines the duration in minutes of temporary blocking
// the user after unsuccessful authentication attempts.
// AuthBlockMin is the duration, in minutes, of the block of new login
// attempts after AuthAttempts unsuccessful login attempts.
AuthBlockMin uint16 `yaml:"block_auth_min"`
ProxyURL string `yaml:"http_proxy"` // Proxy address for our HTTP client
Language string `yaml:"language"` // two-letter ISO 639-1 language code
Expand Down
2 changes: 1 addition & 1 deletion internal/home/home.go
Original file line number Diff line number Diff line change
Expand Up @@ -289,7 +289,7 @@ func run(args options) {
config.AuthAttempts,
)
} else {
log.Info("the authratelimiter is disabled")
log.Info("authratelimiter is disabled")
}
Context.auth = InitAuth(
sessFilename,
Expand Down

0 comments on commit ff4220d

Please sign in to comment.