From 0bdc420e75b3e8aab625a4c6710ff5b16d050402 Mon Sep 17 00:00:00 2001 From: Michael Eischer Date: Mon, 4 Jan 2021 19:05:56 +0100 Subject: [PATCH] Cache successful basic auth credentials for a minute This stores a hash of the username + password in map which is indexed by the username. Indexing by username avoids accidentally introducing a timing side-channel as a successful/failed lookup only provides information on whether a cache entry exists for a username or not. Hashing the username and password together makes it simple to get a constant-time string comparison as we no longer have to worry about string length differences. Expriy is done by a goroutine which every few seconds checks for expired cache entries and removes those. --- htpasswd.go | 66 +++++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 62 insertions(+), 4 deletions(-) diff --git a/htpasswd.go b/htpasswd.go index 263952e..3e257ef 100644 --- a/htpasswd.go +++ b/htpasswd.go @@ -26,6 +26,8 @@ THE SOFTWARE. import ( "crypto/sha1" + "crypto/sha256" + "crypto/subtle" "encoding/base64" "encoding/csv" "log" @@ -42,8 +44,18 @@ import ( // CheckInterval represents how often we check for changes in htpasswd file. const CheckInterval = 30 * time.Second +// PasswordCacheDuration represents how long authentication credentials are +// cached in memory after they were successfully verified. This allows avoiding +// repeatedly verifying the same authentication credentials. +const PasswordCacheDuration = time.Minute + // Lookup passwords in a htpasswd file. The entries must have been created with -s for SHA encryption. +type cacheEntry struct { + expiry time.Time + verifier []byte +} + // HtpasswdFile is a map for usernames to passwords. type HtpasswdFile struct { mutex sync.Mutex @@ -51,6 +63,7 @@ type HtpasswdFile struct { stat os.FileInfo throttle chan struct{} users map[string]string + cache map[string]cacheEntry } // NewHtpasswdFromFile reads the users and passwords from a htpasswd file and returns them. If an error is encountered, @@ -68,6 +81,7 @@ func NewHtpasswdFromFile(path string) (*HtpasswdFile, error) { path: path, stat: stat, throttle: make(chan struct{}), + cache: make(map[string]cacheEntry), } if err := h.Reload(); err != nil { @@ -76,6 +90,7 @@ func NewHtpasswdFromFile(path string) (*HtpasswdFile, error) { // Start a goroutine that limits reload checks to once per CheckInterval go h.throttleTimer() + go h.expiryTimer() go func() { for range c { @@ -100,6 +115,20 @@ func (h *HtpasswdFile) throttleTimer() { } } +func (h *HtpasswdFile) expiryTimer() { + for { + time.Sleep(5 * time.Second) + now := time.Now() + h.mutex.Lock() + for user, entry := range h.cache { + if entry.expiry.After(now) { + delete(h.cache, user) + } + } + h.mutex.Unlock() + } +} + var validUsernameRegexp = regexp.MustCompile(`^[\p{L}\d@._-]+$`) // Reload reloads the htpasswd file. If the reload fails, the Users map is not changed and the error is returned. @@ -130,6 +159,7 @@ func (h *HtpasswdFile) Reload() error { // Replace the Users map h.mutex.Lock() + h.cache = make(map[string]cacheEntry) h.users = users h.mutex.Unlock() @@ -177,30 +207,58 @@ func (h *HtpasswdFile) ReloadCheck() error { func (h *HtpasswdFile) Validate(user string, password string) bool { _ = h.ReloadCheck() + hash := sha256.New() + // hash.Write can never fail + _, _ = hash.Write([]byte(user)) + _, _ = hash.Write([]byte(":")) + _, _ = hash.Write([]byte(password)) + h.mutex.Lock() + // avoid race conditions with cache replacements + cache := h.cache realPassword, exists := h.users[user] + entry, cacheExists := h.cache[user] h.mutex.Unlock() if !exists { return false } + if cacheExists && subtle.ConstantTimeCompare(entry.verifier, hash.Sum(nil)) == 1 { + return true + } + var shaRe = regexp.MustCompile(`^{SHA}`) var bcrRe = regexp.MustCompile(`^\$2b\$|^\$2a\$|^\$2y\$`) + isValid := false + switch { case shaRe.MatchString(realPassword): d := sha1.New() _, _ = d.Write([]byte(password)) if realPassword[5:] == base64.StdEncoding.EncodeToString(d.Sum(nil)) { - return true + isValid = true } case bcrRe.MatchString(realPassword): err := bcrypt.CompareHashAndPassword([]byte(realPassword), []byte(password)) if err == nil { - return true + isValid = true } } - log.Printf("Invalid htpasswd entry for %s.", user) - return false + + if !isValid { + log.Printf("Invalid htpasswd entry for %s.", user) + return false + } + + h.mutex.Lock() + // repurpose mutex to prevent concurrent cache updates + cache[user] = cacheEntry{ + verifier: hash.Sum(nil), + expiry: time.Now().Add(PasswordCacheDuration), + } + h.mutex.Unlock() + + return true }