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.
This commit is contained in:
Matt Holt 2020-06-01 23:56:47 -06:00 committed by GitHub
parent fdf2a77feb
commit 9a7756c6e4
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
2 changed files with 102 additions and 3 deletions

View file

@ -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: <realm>"
@ -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

View file

@ -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