From 9a7756c6e4b4ddb945bede3ddb2dfbf241208915 Mon Sep 17 00:00:00 2001 From: Matt Holt Date: Mon, 1 Jun 2020 23:56:47 -0600 Subject: caddyauth: Cache basicauth results (fixes #3462) (#3465) Cache capacity is currently hard-coded at 1000 with random eviction. It is enabled by default from Caddyfile configurations because I assume this is the most common preference. --- modules/caddyhttp/caddyauth/basicauth.go | 104 ++++++++++++++++++++++++++++++- modules/caddyhttp/caddyauth/caddyfile.go | 1 + 2 files changed, 102 insertions(+), 3 deletions(-) (limited to 'modules') diff --git a/modules/caddyhttp/caddyauth/basicauth.go b/modules/caddyhttp/caddyauth/basicauth.go index d709f94..92e1683 100644 --- a/modules/caddyhttp/caddyauth/basicauth.go +++ b/modules/caddyhttp/caddyauth/basicauth.go @@ -16,15 +16,21 @@ package caddyauth import ( "encoding/base64" + "encoding/hex" "encoding/json" "fmt" + weakrand "math/rand" "net/http" + "sync" + "time" "github.com/caddyserver/caddy/v2" ) func init() { caddy.RegisterModule(HTTPBasicAuth{}) + + weakrand.Seed(time.Now().UnixNano()) } // HTTPBasicAuth facilitates HTTP basic authentication. @@ -38,6 +44,17 @@ type HTTPBasicAuth struct { // The name of the realm. Default: restricted Realm string `json:"realm,omitempty"` + // If non-nil, a mapping of plaintext passwords to their + // hashes will be cached in memory (with random eviction). + // This can greatly improve the performance of traffic-heavy + // servers that use secure password hashing algorithms, with + // the downside that plaintext passwords will be stored in + // memory for a longer time (this should not be a problem + // as long as your machine is not compromised, at which point + // all bets are off, since basicauth necessitates plaintext + // passwords being received over the wire anyway). + HashCache *Cache `json:"hash_cache,omitempty"` + Accounts map[string]Account `json:"-"` Hash Comparer `json:"-"` } @@ -99,6 +116,11 @@ func (hba *HTTPBasicAuth) Provision(ctx caddy.Context) error { } hba.AccountList = nil // allow GC to deallocate + if hba.HashCache != nil { + hba.HashCache.cache = make(map[string]bool) + hba.HashCache.mu = new(sync.Mutex) + } + return nil } @@ -109,13 +131,11 @@ func (hba HTTPBasicAuth) Authenticate(w http.ResponseWriter, req *http.Request) return hba.promptForCredentials(w, nil) } - plaintextPassword := []byte(plaintextPasswordStr) - account, accountExists := hba.Accounts[username] // don't return early if account does not exist; we want // to try to avoid side-channels that leak existence - same, err := hba.Hash.Compare(account.password, plaintextPassword, account.salt) + same, err := hba.correctPassword(account, []byte(plaintextPasswordStr)) if err != nil { return hba.promptForCredentials(w, err) } @@ -126,6 +146,43 @@ func (hba HTTPBasicAuth) Authenticate(w http.ResponseWriter, req *http.Request) return User{ID: username}, true, nil } +func (hba HTTPBasicAuth) correctPassword(account Account, plaintextPassword []byte) (bool, error) { + compare := func() (bool, error) { + return hba.Hash.Compare(account.password, plaintextPassword, account.salt) + } + + // if no caching is enabled, simply return the result of hashing + comparing + if hba.HashCache == nil { + return compare() + } + + // compute a cache key that is unique for these input parameters + cacheKey := hex.EncodeToString(append(append(account.password, account.salt...), plaintextPassword...)) + + // fast track: if the result of the input is already cached, use it + hba.HashCache.mu.Lock() + same, ok := hba.HashCache.cache[cacheKey] + if ok { + hba.HashCache.mu.Unlock() + return same, nil + } + hba.HashCache.mu.Unlock() + + // slow track: do the expensive op, then add it to the cache + same, err := compare() + if err != nil { + return false, err + } + hba.HashCache.mu.Lock() + if len(hba.HashCache.cache) >= 1000 { + hba.HashCache.makeRoom() // keep cache size under control + } + hba.HashCache.cache[cacheKey] = same + hba.HashCache.mu.Unlock() + + return same, nil +} + func (hba HTTPBasicAuth) promptForCredentials(w http.ResponseWriter, err error) (User, bool, error) { // browsers show a message that says something like: // "The website says: " @@ -138,6 +195,47 @@ func (hba HTTPBasicAuth) promptForCredentials(w http.ResponseWriter, err error) return User{}, false, err } +// Cache enables caching of basic auth results. This is especially +// helpful for secure password hashes which can be expensive to +// compute on every HTTP request. +type Cache struct { + mu *sync.Mutex + + // map of concatenated hashed password + plaintext password + salt, to result + cache map[string]bool +} + +// makeRoom deletes about 1/10 of the items in the cache +// in order to keep its size under control. It must not be +// called without a lock on c.mu. +func (c *Cache) makeRoom() { + // we delete more than just 1 entry so that we don't have + // to do this on every request; assuming the capacity of + // the cache is on a long tail, we can save a lot of CPU + // time by doing a whole bunch of deletions now and then + // we won't have to do them again for a while + numToDelete := len(c.cache) / 10 + if numToDelete < 1 { + numToDelete = 1 + } + for deleted := 0; deleted <= numToDelete; deleted++ { + // Go maps are "nondeterministic" not actually random, + // so although we could just chop off the "front" of the + // map with less code, this is a heavily skewed eviction + // strategy; generating random numbers is cheap and + // ensures a much better distribution. + rnd := weakrand.Intn(len(c.cache)) + i := 0 + for key := range c.cache { + if i == rnd { + delete(c.cache, key) + break + } + i++ + } + } +} + // Comparer is a type that can securely compare // a plaintext password with a hashed password // in constant-time. Comparers should hash the diff --git a/modules/caddyhttp/caddyauth/caddyfile.go b/modules/caddyhttp/caddyauth/caddyfile.go index 9fe8a80..13e78fc 100644 --- a/modules/caddyhttp/caddyauth/caddyfile.go +++ b/modules/caddyhttp/caddyauth/caddyfile.go @@ -35,6 +35,7 @@ func init() { // If no hash algorithm is supplied, bcrypt will be assumed. func parseCaddyfile(h httpcaddyfile.Helper) (caddyhttp.MiddlewareHandler, error) { var ba HTTPBasicAuth + ba.HashCache = new(Cache) for h.Next() { var cmp Comparer -- cgit v1.2.3