mirror of
https://github.com/restic/rest-server.git
synced 2026-09-12 07:06:09 -07:00
Merge pull request #138 from MichaelEischer/cache-basic-auth
Cache basic auth credentials
This commit is contained in:
@@ -0,0 +1,9 @@
|
|||||||
|
Enhancement: Cache basic auth credentials
|
||||||
|
|
||||||
|
To speed up the verification of basic auth credentials, rest-server now caches
|
||||||
|
passwords for a minute in memory. That way the expensive verification of basic
|
||||||
|
auth credentials can be skipped for most requests issued by a single restic
|
||||||
|
run. The password is kept in memory in a hashed form and not as plaintext.
|
||||||
|
|
||||||
|
https://github.com/restic/rest-server/issues/133
|
||||||
|
https://github.com/restic/rest-server/pull/138
|
||||||
+92
-14
@@ -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,15 +44,26 @@ 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
|
||||||
path string
|
path string
|
||||||
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,23 @@ func (h *HtpasswdFile) throttleTimer() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *HtpasswdFile) expiryTimer() {
|
||||||
|
for {
|
||||||
|
time.Sleep(5 * time.Second)
|
||||||
|
now := time.Now()
|
||||||
|
h.mutex.Lock()
|
||||||
|
var zeros [sha256.Size]byte
|
||||||
|
// try to wipe expired cache entries
|
||||||
|
for user, entry := range h.cache {
|
||||||
|
if entry.expiry.After(now) {
|
||||||
|
copy(entry.verifier, zeros[:])
|
||||||
|
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,7 +162,14 @@ func (h *HtpasswdFile) Reload() error {
|
|||||||
|
|
||||||
// Replace the Users map
|
// Replace the Users map
|
||||||
h.mutex.Lock()
|
h.mutex.Lock()
|
||||||
h.Users = users
|
var zeros [sha256.Size]byte
|
||||||
|
// try to wipe the old cache entries
|
||||||
|
for _, entry := range h.cache {
|
||||||
|
copy(entry.verifier, zeros[:])
|
||||||
|
}
|
||||||
|
h.cache = make(map[string]cacheEntry)
|
||||||
|
|
||||||
|
h.users = users
|
||||||
h.mutex.Unlock()
|
h.mutex.Unlock()
|
||||||
|
|
||||||
_ = r.Close()
|
_ = r.Close()
|
||||||
@@ -172,35 +211,74 @@ func (h *HtpasswdFile) ReloadCheck() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var shaRe = regexp.MustCompile(`^{SHA}`)
|
||||||
|
var bcrRe = regexp.MustCompile(`^\$2b\$|^\$2a\$|^\$2y\$`)
|
||||||
|
|
||||||
// Validate returns true if password matches the stored password for user. If no password for user is stored, or the
|
// Validate returns true if password matches the stored password for user. If no password for user is stored, or the
|
||||||
// password is wrong, false is returned.
|
// password is wrong, false is returned.
|
||||||
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()
|
||||||
realPassword, exists := h.Users[user]
|
// avoid race conditions with cache replacements
|
||||||
|
cache := h.cache
|
||||||
|
hashedPassword, exists := h.users[user]
|
||||||
|
entry, cacheExists := h.cache[user]
|
||||||
h.mutex.Unlock()
|
h.mutex.Unlock()
|
||||||
|
|
||||||
if !exists {
|
if !exists {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
var shaRe = regexp.MustCompile(`^{SHA}`)
|
if cacheExists && subtle.ConstantTimeCompare(entry.verifier, hash.Sum(nil)) == 1 {
|
||||||
var bcrRe = regexp.MustCompile(`^\$2b\$|^\$2a\$|^\$2y\$`)
|
h.mutex.Lock()
|
||||||
|
// repurpose mutex to prevent concurrent cache updates
|
||||||
switch {
|
// extend cache entry
|
||||||
case shaRe.MatchString(realPassword):
|
cache[user] = cacheEntry{
|
||||||
d := sha1.New()
|
verifier: entry.verifier,
|
||||||
_, _ = d.Write([]byte(password))
|
expiry: time.Now().Add(PasswordCacheDuration),
|
||||||
if realPassword[5:] == base64.StdEncoding.EncodeToString(d.Sum(nil)) {
|
}
|
||||||
|
h.mutex.Unlock()
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
case bcrRe.MatchString(realPassword):
|
|
||||||
err := bcrypt.CompareHashAndPassword([]byte(realPassword), []byte(password))
|
isValid := isMatchingHashAndPassword(hashedPassword, password)
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
func isMatchingHashAndPassword(hashedPassword string, password string) bool {
|
||||||
|
switch {
|
||||||
|
case shaRe.MatchString(hashedPassword):
|
||||||
|
d := sha1.New()
|
||||||
|
_, _ = d.Write([]byte(password))
|
||||||
|
if subtle.ConstantTimeCompare([]byte(hashedPassword[5:]), []byte(base64.StdEncoding.EncodeToString(d.Sum(nil)))) == 1 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
case bcrRe.MatchString(hashedPassword):
|
||||||
|
err := bcrypt.CompareHashAndPassword([]byte(hashedPassword), []byte(password))
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
log.Printf("Invalid htpasswd entry for %s.", user)
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package restserver
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io/ioutil"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestValidate(t *testing.T) {
|
||||||
|
user := "restic"
|
||||||
|
pwd := "$2y$05$z/OEmNQamd6m6LSegUErh.r/Owk9Xwmc5lxDheIuHY2Z7XiS6FtJm"
|
||||||
|
rawPwd := "test"
|
||||||
|
wrongPwd := "wrong"
|
||||||
|
|
||||||
|
tmpfile, err := ioutil.TempFile("", "rest-validate-")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err = tmpfile.Write([]byte(user + ":" + pwd + "\n")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err = tmpfile.Close(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
htpass, err := NewHtpasswdFromFile(tmpfile.Name())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := 0; i < 10; i++ {
|
||||||
|
isValid := htpass.Validate(user, rawPwd)
|
||||||
|
if !isValid {
|
||||||
|
t.Fatal("correct password not accepted")
|
||||||
|
}
|
||||||
|
|
||||||
|
isValid = htpass.Validate(user, wrongPwd)
|
||||||
|
if isValid {
|
||||||
|
t.Fatal("wrong password accepted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = os.Remove(tmpfile.Name()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user