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.
This commit is contained in:
Michael Eischer 2021-01-04 19:05:56 +01:00 committed by Leo R. Lundgren
parent b0036d006b
commit 0bdc420e75

View file

@ -26,6 +26,8 @@ THE SOFTWARE.
import ( import (
"crypto/sha1" "crypto/sha1"
"crypto/sha256"
"crypto/subtle"
"encoding/base64" "encoding/base64"
"encoding/csv" "encoding/csv"
"log" "log"
@ -42,8 +44,18 @@ import (
// CheckInterval represents how often we check for changes in htpasswd file. // CheckInterval represents how often we check for changes in htpasswd file.
const CheckInterval = 30 * time.Second 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. // 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. // HtpasswdFile is a map for usernames to passwords.
type HtpasswdFile struct { type HtpasswdFile struct {
mutex sync.Mutex mutex sync.Mutex
@ -51,6 +63,7 @@ type HtpasswdFile struct {
stat os.FileInfo stat os.FileInfo
throttle chan struct{} throttle chan struct{}
users map[string]string 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, // 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, path: path,
stat: stat, stat: stat,
throttle: make(chan struct{}), throttle: make(chan struct{}),
cache: make(map[string]cacheEntry),
} }
if err := h.Reload(); err != nil { 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 // Start a goroutine that limits reload checks to once per CheckInterval
go h.throttleTimer() go h.throttleTimer()
go h.expiryTimer()
go func() { go func() {
for range c { 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@._-]+$`) 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. // 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 // Replace the Users map
h.mutex.Lock() h.mutex.Lock()
h.cache = make(map[string]cacheEntry)
h.users = users h.users = users
h.mutex.Unlock() h.mutex.Unlock()
@ -177,30 +207,58 @@ func (h *HtpasswdFile) ReloadCheck() error {
func (h *HtpasswdFile) Validate(user string, password string) bool { func (h *HtpasswdFile) Validate(user string, password string) bool {
_ = h.ReloadCheck() _ = h.ReloadCheck()
hash := sha256.New()
// hash.Write can never fail
_, _ = hash.Write([]byte(user))
_, _ = hash.Write([]byte(":"))
_, _ = hash.Write([]byte(password))
h.mutex.Lock() h.mutex.Lock()
// avoid race conditions with cache replacements
cache := h.cache
realPassword, exists := h.users[user] realPassword, exists := h.users[user]
entry, cacheExists := h.cache[user]
h.mutex.Unlock() h.mutex.Unlock()
if !exists { if !exists {
return false return false
} }
if cacheExists && subtle.ConstantTimeCompare(entry.verifier, hash.Sum(nil)) == 1 {
return true
}
var shaRe = regexp.MustCompile(`^{SHA}`) var shaRe = regexp.MustCompile(`^{SHA}`)
var bcrRe = regexp.MustCompile(`^\$2b\$|^\$2a\$|^\$2y\$`) var bcrRe = regexp.MustCompile(`^\$2b\$|^\$2a\$|^\$2y\$`)
isValid := false
switch { switch {
case shaRe.MatchString(realPassword): case shaRe.MatchString(realPassword):
d := sha1.New() d := sha1.New()
_, _ = d.Write([]byte(password)) _, _ = d.Write([]byte(password))
if realPassword[5:] == base64.StdEncoding.EncodeToString(d.Sum(nil)) { if realPassword[5:] == base64.StdEncoding.EncodeToString(d.Sum(nil)) {
return true isValid = true
} }
case bcrRe.MatchString(realPassword): case bcrRe.MatchString(realPassword):
err := bcrypt.CompareHashAndPassword([]byte(realPassword), []byte(password)) err := bcrypt.CompareHashAndPassword([]byte(realPassword), []byte(password))
if err == nil { 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
} }