Commit 92aea0b7 authored by ale's avatar ale
Browse files

Add configurable rate limiting / blacklisting support

parent 6a2216a2
......@@ -71,16 +71,122 @@ type U2FShortTermStorage interface {
GetUserChallenge(string) (*u2f.Challenge, bool)
}
type ratelimitKeyFunc func(*User, *auth.Request) string
func usernameKey(user *User, _ *auth.Request) string {
return user.Name
}
func ipAddrKey(_ *User, req *auth.Request) string {
if req.DeviceInfo != nil {
return req.DeviceInfo.RemoteAddr
}
return ""
}
type authRatelimiterConfig struct {
Limit int `yaml:"limit"`
Period int `yaml:"period"`
BlacklistTime int `yaml:"blacklist_for"`
OnFailure bool `yaml:"on_failure"`
Keys []string `yaml:"keys"`
keyFuncs []ratelimitKeyFunc
}
func (r *authRatelimiterConfig) compile() error {
for _, k := range r.Keys {
var f ratelimitKeyFunc
switch k {
case "ip":
f = ipAddrKey
case "user":
f = usernameKey
default:
return fmt.Errorf("unknown key %s", k)
}
r.keyFuncs = append(r.keyFuncs, f)
}
return nil
}
const rlKeySep = ";"
func (r *authRatelimiterConfig) key(user *User, req *auth.Request) string {
if len(r.keyFuncs) == 1 {
return r.keyFuncs[0](user, req)
}
var parts []string
for _, f := range r.keyFuncs {
parts = append(parts, f(user, req))
}
return strings.Join(parts, rlKeySep)
}
type authRatelimiter struct {
*authRatelimiterConfig
rl *Ratelimiter
}
func (r *authRatelimiter) AllowIncr(user *User, req *auth.Request) bool {
return r.rl.AllowIncr(r.key(user, req))
}
type authBlacklist struct {
*authRatelimiterConfig
bl *Blacklist
}
func (b *authBlacklist) Allow(user *User, req *auth.Request) bool {
return b.bl.Allow(b.key(user, req))
}
func (b *authBlacklist) Incr(user *User, req *auth.Request, resp *auth.Response) {
if b.OnFailure && resp.Status == auth.StatusOK {
return
}
b.bl.Incr(b.key(user, req))
}
type requestFilterFunc func(*User, *auth.Request, *auth.Response) *auth.Response
// BackendSpec specifies backend-specific configuration for a service.
type BackendSpec struct {
LDAPSpec *LDAPServiceConfig `yaml:"ldap"`
FileSpec string `yaml:"file"`
}
// ServiceConfig configures authentication backends for a service.
// ServiceConfig defines the authentication backends for a service.
type ServiceConfig struct {
BackendSpecs []*BackendSpec `yaml:"backends"`
ChallengeResponse bool `yaml:"challenge_response"`
Ratelimits []string `yaml:"rate_limits"`
rl []*authRatelimiter
bl []*authBlacklist
filters []requestFilterFunc
}
func (c *ServiceConfig) checkRateLimits(user *User, req *auth.Request) bool {
for _, rl := range c.rl {
if !rl.AllowIncr(user, req) {
return false
}
}
for _, bl := range c.bl {
if !bl.Allow(user, req) {
return false
}
}
return true
}
func (c *ServiceConfig) notifyBlacklists(user *User, req *auth.Request, resp *auth.Response) {
for _, bl := range c.bl {
bl.Incr(user, req, resp)
}
}
// Config for the authentication server.
......@@ -94,6 +200,14 @@ type Config struct {
// Service-specific configuration.
Services map[string]*ServiceConfig `yaml:"services"`
// Named rate limiter configurations.
RateLimiters map[string]*authRatelimiterConfig `yaml:"rate_limits"`
// Runtime versions of the above. These objects are shared by
// all services, as they contain the actual map data.
rl map[string]*Ratelimiter
bl map[string]*Blacklist
path string
}
......@@ -115,6 +229,39 @@ func (c *Config) relativePath(path string) string {
return filepath.Join(filepath.Dir(c.path), path)
}
func (c *Config) compile() error {
c.rl = make(map[string]*Ratelimiter)
c.bl = make(map[string]*Blacklist)
for name, params := range c.RateLimiters {
if err := params.compile(); err != nil {
return err
}
if params.BlacklistTime > 0 {
c.bl[name] = newBlacklist(params.Limit, params.Period, params.BlacklistTime)
} else {
c.rl[name] = newRatelimiter(params.Limit, params.Period)
}
}
for _, sc := range c.Services {
for _, name := range sc.Ratelimits {
config, ok := c.RateLimiters[name]
if !ok {
return fmt.Errorf("unknown rate limiter %s", name)
}
if rl, ok := c.rl[name]; ok {
sc.rl = append(sc.rl, &authRatelimiter{config, rl})
} else if bl, ok := c.bl[name]; ok {
sc.bl = append(sc.bl, &authBlacklist{config, bl})
} else {
panic("can't find rl/bl")
}
}
}
return nil
}
// LoadConfig loads the configuration from a YAML-encoded file.
func LoadConfig(path string) (*Config, error) {
data, err := ioutil.ReadFile(path)
......@@ -125,6 +272,10 @@ func LoadConfig(path string) (*Config, error) {
if err := yaml.Unmarshal(data, &config); err != nil {
return nil, err
}
log.Printf("configuration: %+v", config)
if err := config.compile(); err != nil {
return nil, err
}
return &config, nil
}
......@@ -183,6 +334,17 @@ func (s *Server) getServiceConfig(service string) (*ServiceConfig, bool) {
return c, ok
}
func (s *Server) getUser(ctx context.Context, serviceConfig *ServiceConfig, username string) (*User, bool) {
for _, spec := range serviceConfig.BackendSpecs {
for _, b := range s.backends {
if user, ok := b.GetUser(ctx, spec, username); ok {
return user, true
}
}
}
return nil, false
}
// Authenticate a user with the parameters specified in the incoming AuthRequest.
func (s *Server) Authenticate(ctx context.Context, req *auth.Request) *auth.Response {
serviceConfig, ok := s.getServiceConfig(req.Service)
......@@ -191,22 +353,28 @@ func (s *Server) Authenticate(ctx context.Context, req *auth.Request) *auth.Resp
return newError()
}
var user *User
outer:
for _, spec := range serviceConfig.BackendSpecs {
for _, b := range s.backends {
if user, ok = b.GetUser(ctx, spec, req.Username); ok {
break outer
}
}
}
user, ok := s.getUser(ctx, serviceConfig, req.Username)
if !ok {
// User is unknown to all backends.
log.Printf("unknown user %s", req.Username)
return newError()
}
// Apply rate limiting and blacklisting _before_ invoking the
// authentication handlers, as they may be CPU intensive.
if allowed := serviceConfig.checkRateLimits(user, req); !allowed {
return newError()
}
resp := s.authenticateUser(req, serviceConfig, user)
// Notify blacklists of the result.
serviceConfig.notifyBlacklists(user, req, resp)
return resp
}
func (s *Server) authenticateUser(req *auth.Request, serviceConfig *ServiceConfig, user *User) *auth.Response {
// Verify different credentials depending on whether the user
// has 2FA enabled or not, and on whether the service itself
// supports challenge-response authentication.
......@@ -218,7 +386,16 @@ outer:
resp = s.authenticateUserWithASP(user, req)
}
} else {
resp = s.authenticateUser(user, req)
resp = s.authenticateUserWithPassword(user, req)
}
// Process the response through filters (device info checks,
// etc) that may or may not change the response itself.
for _, f := range serviceConfig.filters {
if resp.Status == auth.StatusError {
break
}
resp = f(user, req, resp)
}
// If the response is successful, augment it with user information.
......@@ -229,7 +406,7 @@ outer:
return resp
}
func (s *Server) authenticateUser(user *User, req *auth.Request) *auth.Response {
func (s *Server) authenticateUserWithPassword(user *User, req *auth.Request) *auth.Response {
// Ok we only need to check the password here.
if checkPassword(req.Password, user.EncryptedPassword) {
return newOK()
......
......@@ -89,6 +89,24 @@ services:
backends:
- { file: users.yml }
`
testConfigStrWithRatelimit = `---
enabled_backends:
- file
services:
test:
backends:
- { file: users.yml }
rate_limits:
- failed_login_bl
rate_limits:
failed_login_bl:
limit: 10
period: 300
blacklist_for: 3600
on_failure: true
keys: [user]
`
)
func runAuthenticationTest(t *testing.T, client client.Client) {
......@@ -160,3 +178,56 @@ func TestAuthServer(t *testing.T) {
defer s.Close()
runAuthenticationTest(t, &clientAdapter{s.srv})
}
func TestAuthServer_Blacklist(t *testing.T) {
s := createTestServer(t, map[string]string{
"users.yml": testUsersFileStr,
"config.yml": testConfigStrWithRatelimit,
})
defer s.Close()
c := &clientAdapter{s.srv}
// Trigger the failed login blacklist, then verify that the
// user is blacklisted even when trying with the right password.
for i := 0; i < 100; i++ {
c.Authenticate(context.Background(), &auth.Request{
Service: "test",
Username: "testuser",
Password: []byte("bad_password"),
})
}
resp, _ := c.Authenticate(context.Background(), &auth.Request{
Service: "test",
Username: "testuser",
Password: []byte("password"),
})
if resp.Status != auth.StatusError {
t.Fatalf("user was not blacklisted: %v", resp)
}
}
func TestAuthServer_Blacklist_BelowLimit(t *testing.T) {
s := createTestServer(t, map[string]string{
"users.yml": testUsersFileStr,
"config.yml": testConfigStrWithRatelimit,
})
defer s.Close()
c := &clientAdapter{s.srv}
// A small number of failures should not trigger the blacklist.
for i := 0; i < 8; i++ {
c.Authenticate(context.Background(), &auth.Request{
Service: "test",
Username: "testuser",
Password: []byte("bad_password"),
})
}
resp, _ := c.Authenticate(context.Background(), &auth.Request{
Service: "test",
Username: "testuser",
Password: []byte("password"),
})
if resp.Status != auth.StatusOK {
t.Fatalf("user was incorrectly blacklisted: %+v", s.srv.config.Services["test"])
}
}
package server
import (
"sync"
"time"
)
// Try to use as little memory as possible for each entry: use a UNIX
// timestamp instead of a time.Time, and use an int32 as a saturating
// counter.
type ratelimitDatum struct {
stamp int64
counter int32
}
func (d ratelimitDatum) age(now int64) int64 {
return now - d.stamp
}
// Ratelimiter is a simple counter-based rate limiter, allowing the
// first N requests over each period of time T.
type Ratelimiter struct {
limit int32
period int64
mx sync.Mutex
c map[string]ratelimitDatum
}
func newRatelimiter(limit, period int) *Ratelimiter {
r := &Ratelimiter{
limit: int32(limit),
period: int64(period),
c: make(map[string]ratelimitDatum),
}
go r.expungeThread()
return r
}
// AllowIncr performs a check and an increment at the same time, while
// holding a mutex, so it is robust in face of concurrent requests.
func (r *Ratelimiter) AllowIncr(key string) bool {
if key == "" {
return true
}
r.mx.Lock()
d := r.get(key)
var allowed bool
if d.counter <= r.limit {
allowed = true
d.counter++
r.set(key, d)
}
r.mx.Unlock()
return allowed
}
func (r *Ratelimiter) get(key string) ratelimitDatum {
now := time.Now().Unix()
d, ok := r.c[key]
if !ok || d.age(now) > r.period {
d = ratelimitDatum{stamp: now}
r.c[key] = d
}
return d
}
func (r *Ratelimiter) set(key string, d ratelimitDatum) {
r.c[key] = d
}
func (r *Ratelimiter) expunge() {
cutoff := time.Now().Unix() - 2*r.period
r.mx.Lock()
for k, d := range r.c {
if d.stamp < cutoff {
delete(r.c, k)
}
}
r.mx.Unlock()
}
var ratelimitExpungePeriod = 300 * time.Second
func (r *Ratelimiter) expungeThread() {
for range time.NewTicker(ratelimitExpungePeriod).C {
r.expunge()
}
}
// Blacklist can blacklist keys whose request rate is above a
// specified threshold.
type Blacklist struct {
r *Ratelimiter
bl map[string]int64
blTime int64
}
func newBlacklist(limit, period, blacklistTime int) *Blacklist {
return &Blacklist{
r: newRatelimiter(limit, period),
bl: make(map[string]int64),
blTime: int64(blacklistTime),
}
}
// Allow returns true if this request (identified by the given key)
// should be allowed.
func (b *Blacklist) Allow(key string) bool {
if key == "" {
return true
}
b.r.mx.Lock()
deadline, ok := b.bl[key]
if ok && deadline < time.Now().Unix() {
delete(b.bl, key)
ok = false
}
b.r.mx.Unlock()
return !ok
}
// Incr increments the counter for the given key for the current time
// period.
func (b *Blacklist) Incr(key string) {
if key == "" {
return
}
// Count one higher than limit, and trigger the blacklist when
// we reach that.
limitp1 := b.r.limit + 1
b.r.mx.Lock()
d := b.r.get(key)
if d.counter < limitp1 {
d.counter++
b.r.set(key, d)
} else if d.counter == limitp1 {
b.bl[key] = time.Now().Unix() + b.blTime
}
b.r.mx.Unlock()
}
Supports Markdown
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment