Commit c06fa1a7 authored by ale's avatar ale
Browse files

Add rate limiting and anti-brute-force blacklists to login service

Will limit requests per-IP, and block addresses and users that have
too many failed logins.
parent 8551e97a
Pipeline #411 passed with stages
in 1 minute and 17 seconds
......@@ -60,8 +60,9 @@ type serverCommand struct {
actionAuthKey string
actionEncKey string
senderAddr string
senderName string
senderAddr string
senderName string
ratelimitConfigFile string
}
func newServerCommand() *serverCommand { return &serverCommand{} }
......@@ -98,6 +99,7 @@ func (c *serverCommand) SetFlags(f *flag.FlagSet) {
f.StringVar(&c.actionEncKey, "action-enc-key", "", "encryption key for e-mail action tokens")
f.StringVar(&c.senderAddr, "sender-addr", "idp@localhost", "email sender address")
f.StringVar(&c.senderName, "sender-name", "IDP", "email sender name")
f.StringVar(&c.ratelimitConfigFile, "ratelimit-config", "", "configuration for rate limiters / blacklists")
setFlagDefaultsFromEnv(f)
}
......@@ -179,6 +181,16 @@ func (c *serverCommand) run(ctx context.Context) error {
htmlTpl := htemplate.Must(htemplate.ParseGlob("./templates/*.html"))
emailTpl := ttemplate.Must(ttemplate.ParseGlob("./templates/email/*.txt"))
var rlConfig []byte
if c.ratelimitConfigFile != "" {
data, err := ioutil.ReadFile(c.ratelimitConfigFile)
if err != nil {
log.Warn("could not read ratelimit config: %v", err)
} else {
rlConfig = data
}
}
app, err := idpapp.NewApp(hc, &idpapp.Config{
PublicURL: c.publicURL,
CookieAuthKey: []byte(c.cookieAuthKey),
......@@ -191,6 +203,7 @@ func (c *serverCommand) run(ctx context.Context) error {
EmailTemplate: emailTpl,
SenderAddr: c.senderAddr,
SenderName: c.senderName,
RatelimitConfig: rlConfig,
})
if err != nil {
return err
......
......@@ -32,6 +32,7 @@ type Config struct {
SenderAddr string
SenderName string
Database idp.Database
RatelimitConfig []byte
}
// App is the main login / consent / identity provider application.
......@@ -44,6 +45,11 @@ type App struct {
// NewApp creates a new application.
func NewApp(hc *hydra.Client, config *Config) (*App, error) {
rlConfig, err := web.ParseRatelimitConfig(config.RatelimitConfig)
if err != nil {
return nil, err
}
if config.Mailer == nil {
config.Mailer = &sendmailBackend{}
}
......@@ -54,7 +60,7 @@ func NewApp(hc *hydra.Client, config *Config) (*App, error) {
svcBase := web.NewService(config.Database, config.Template, store, mailer, config.InsecureCookies)
loginSrv := login.NewService(svcBase, publicURL, config.InsecureCookies)
loginSrv := login.NewService(svcBase, publicURL, rlConfig, config.InsecureCookies)
acct := mgmt.NewService(svcBase, actionMgr)
admin := admin.NewService(svcBase, hc, actionMgr, publicURL)
idpSrv, err := consent.NewService(svcBase, store, hc, config.InsecureCookies)
......
......@@ -46,10 +46,15 @@ type Service struct {
*web.Service
loginSessionOptions *sessions.Options
publicURL string
// Ratelimiters and blacklists.
userFailedLoginBL *web.Blacklist
ipFailedLoginBL *web.Blacklist
userLoginRL *web.Ratelimiter
}
// NewService returns a new Service.
func NewService(base *web.Service, publicURL string, insecureCookies bool) *Service {
func NewService(base *web.Service, publicURL string, rlConfig web.RatelimitConfig, insecureCookies bool) *Service {
return &Service{
Service: base,
loginSessionOptions: &sessions.Options{
......@@ -58,7 +63,10 @@ func NewService(base *web.Service, publicURL string, insecureCookies bool) *Serv
HttpOnly: true,
Secure: !insecureCookies,
},
publicURL: publicURL,
publicURL: publicURL,
userFailedLoginBL: rlConfig.NewBlacklist("failed_login_user_bl"),
ipFailedLoginBL: rlConfig.NewBlacklist("failed_login_ip_bl"),
userLoginRL: rlConfig.NewRatelimiter("login_user_rl"),
}
}
......@@ -124,6 +132,20 @@ func (s *Service) handleLoginPOST(w http.ResponseWriter, r *http.Request, ls *lo
username := r.FormValue("username")
password := r.FormValue("password")
// Do not leak information about the blacklist: if the request
// is blacklisted, show the form again. A more HTTP-friendly
// alternative would be:
//
// http.Error(w, "Ratelimited", http.StatusTooManyRequests)
//
if !s.checkBlacklists(r, username) {
s.RenderTemplate(w, r, "login.html", map[string]interface{}{
"Username": username,
"AuthError": true,
})
return
}
// In order to successfully authenticate a user: it must
// exist, it must be in the 'active' state, and the given
// password should match.
......@@ -133,6 +155,7 @@ func (s *Service) handleLoginPOST(w http.ResponseWriter, r *http.Request, ls *lo
return
})
if err != nil || user.State != idp.UserStateActive || !user.AuthenticatePassword(password) {
s.failedLogin(r, username)
log.WithFields(log.Fields{
"user": username,
}).Info("failed login attempt")
......@@ -161,6 +184,14 @@ func (s *Service) handleLoginPOST(w http.ResponseWriter, r *http.Request, ls *lo
}
func (s *Service) handleOTP(w http.ResponseWriter, r *http.Request, ls *loginSession) {
if !s.checkBlacklists(r, ls.Username) {
s.RenderTemplate(w, r, "login_otp.html", map[string]interface{}{
"Username": ls.Username,
"AuthError": true,
})
return
}
var user *idp.User
err := s.DB.Do(r.Context(), func(txn idp.Txn) (err error) {
user, err = txn.GetUser(ls.Username)
......@@ -182,6 +213,7 @@ func (s *Service) handleOTP(w http.ResponseWriter, r *http.Request, ls *loginSes
log.WithFields(log.Fields{
"user": ls.Username,
}).Info("failed OTP attempt")
s.failedLogin(r, ls.Username)
}
s.RenderTemplate(w, r, "login_otp.html", map[string]interface{}{
......@@ -191,6 +223,47 @@ func (s *Service) handleOTP(w http.ResponseWriter, r *http.Request, ls *loginSes
})
}
// Fetch remote IP address from the request.
func (s *Service) getIP(r *http.Request) (ip string) {
if addr, err := web.GetRemoteAddr(r); err == nil {
// Skip localhost and other well-known addresses.
ip = addr.String()
}
return
}
// Called before a login attempt to check the request against
// blacklists. Returns true if the request is allowed. Must be called
// by every protected HTTP endpoint.
func (s *Service) checkBlacklists(r *http.Request, username string) bool {
// Check request against per-user ratelimit.
if s.userLoginRL != nil && !s.userLoginRL.AllowIncr(username) {
return false
}
// Check the username against the failed-logins blacklists.
if s.userFailedLoginBL != nil && !s.userFailedLoginBL.Allow(username) {
return false
}
ip := s.getIP(r)
if ip != "" && s.ipFailedLoginBL != nil && !s.ipFailedLoginBL.Allow(ip) {
return false
}
return true
}
// failedLogin is called on login failures, and increments the various
// blacklist counters.
func (s *Service) failedLogin(r *http.Request, username string) {
if s.userFailedLoginBL != nil {
s.userFailedLoginBL.Incr(username)
}
if s.ipFailedLoginBL != nil {
s.ipFailedLoginBL.Incr(s.getIP(r))
}
}
func (s *Service) successfulLogin(w http.ResponseWriter, r *http.Request, username string) error {
// Check out the device information.
dev, isNew := web.GetDeviceInfoFromRequest(r, s.Store)
......@@ -333,8 +406,8 @@ func (s *Service) signU2FResponse(w http.ResponseWriter, r *http.Request, ls *lo
newCounter, authErr := reg.Authenticate(u2fResp, *challenge, reg.Counter)
if authErr == nil {
reg.Counter = newCounter
if err := txn.UpdateUserU2FRegistration(ls.Username, reg); err != nil {
return err
if regErr := txn.UpdateUserU2FRegistration(ls.Username, reg); regErr != nil {
return regErr
}
ls.State = sessionStateAuthenticated
......
package web
import (
"encoding/json"
"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
}
// 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.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) 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()
}
func (r *Ratelimiter) expungeThread() {
for range time.NewTicker(60 * time.Second).C {
r.expunge()
}
}
type Blacklist struct {
r *Ratelimiter
bl map[string]int64
blTime int64
}
func newBlacklist(limit, period, blTime int) *Blacklist {
return &Blacklist{
r: newRatelimiter(limit, period),
bl: make(map[string]int64),
blTime: int64(blTime),
}
}
// 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++
} else if d.counter == limitp1 {
b.bl[key] = time.Now().Unix() + b.blTime
}
b.r.mx.Unlock()
}
type RatelimiterParams struct {
Enabled bool `json:"enabled"`
Limit int `json:"limit"`
Period int `json:"period"`
BlacklistTime int `json:"blacklist_time"`
}
type RatelimitConfig map[string]*RatelimiterParams
var defaultRatelimitConfig = map[string]*RatelimiterParams{
"failed_login_ip_bl": &RatelimiterParams{
Enabled: true,
Limit: 10,
Period: 300,
BlacklistTime: 7200,
},
"failed_login_user_bl": &RatelimiterParams{
Enabled: true,
Limit: 10,
Period: 300,
BlacklistTime: 1800,
},
"login_user_rl": &RatelimiterParams{
Enabled: true,
Limit: 10,
Period: 300,
},
}
func ParseRatelimitConfig(data []byte) (RatelimitConfig, error) {
config := make(map[string]*RatelimiterParams)
for k, v := range defaultRatelimitConfig {
config[k] = v
}
if data != nil {
if err := json.Unmarshal(data, config); err != nil {
return nil, err
}
}
return config, nil
}
func (c RatelimitConfig) NewRatelimiter(name string) *Ratelimiter {
rlconf := c[name]
if !rlconf.Enabled {
return nil
}
return newRatelimiter(rlconf.Limit, rlconf.Period)
}
func (c RatelimitConfig) NewBlacklist(name string) *Blacklist {
blconf := c[name]
if !blconf.Enabled {
return nil
}
return newBlacklist(blconf.Limit, blconf.Period, blconf.BlacklistTime)
}
Markdown is supported
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