mirror of
https://github.com/restic/rest-server.git
synced 2025-10-19 15:43:21 +00:00
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:
parent
b0036d006b
commit
0bdc420e75
1 changed files with 62 additions and 4 deletions
62
htpasswd.go
62
htpasswd.go
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !isValid {
|
||||||
log.Printf("Invalid htpasswd entry for %s.", user)
|
log.Printf("Invalid htpasswd entry for %s.", user)
|
||||||
return false
|
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
|
||||||
}
|
}
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue