144 lines
4.4 KiB
Go
144 lines
4.4 KiB
Go
package main
|
|||
|
|
|
||
|
|
import (
|
||
|
|
"net/http"
|
||
|
|
"net/http/httptest"
|
||
|
|
"os"
|
||
|
|
"path/filepath"
|
||
|
|
"strings"
|
||
|
|
"testing"
|
||
|
|
"time"
|
||
|
|
|
||
|
|
"golang.org/x/crypto/bcrypt"
|
||
|
|
"golang.org/x/time/rate"
|
||
|
|
)
|
||
|
|
|
||
|
|
// bcryptHash returns a cost-10 hash of pw, matching `htpasswd -B`'s default.
|
||
|
|
func bcryptHash(t *testing.T, pw string) string {
|
||
|
|
t.Helper()
|
||
|
|
h, err := bcrypt.GenerateFromPassword([]byte(pw), 10)
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("GenerateFromPassword: %v", err)
|
||
|
|
}
|
||
|
|
return string(h)
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestLoadHtpasswdAndCheck(t *testing.T) {
|
||
|
|
// alice's hash is rewritten to the $2y$ variant that Apache's htpasswd -B
|
||
|
|
// emits, to prove it is accepted. $2a$ and $2y$ are the same algorithm, so
|
||
|
|
// swapping the prefix on a valid hash yields a valid hash for the same
|
||
|
|
// password. bob keeps the $2a$ form.
|
||
|
|
aliceHash := bcryptHash(t, "s3cret")
|
||
|
|
if !strings.HasPrefix(aliceHash, "$2a$") {
|
||
|
|
t.Fatalf("expected $2a$ prefix, got %q", aliceHash[:4])
|
||
|
|
}
|
||
|
|
aliceHashY := "$2y$" + strings.TrimPrefix(aliceHash, "$2a$")
|
||
|
|
bobHash := bcryptHash(t, "hunter2")
|
||
|
|
|
||
|
|
file := filepath.Join(t.TempDir(), "htpasswd")
|
||
|
|
content := strings.Join([]string{
|
||
|
|
"# a comment",
|
||
|
|
"",
|
||
|
|
"alice:" + aliceHashY,
|
||
|
|
"bob:" + bobHash,
|
||
|
|
"legacy:$apr1$abcd$0123456789", // MD5 — must be skipped
|
||
|
|
}, "\n") + "\n"
|
||
|
|
if err := os.WriteFile(file, []byte(content), 0600); err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
|
||
|
|
a, err := LoadHtpasswd(file)
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("LoadHtpasswd: %v", err)
|
||
|
|
}
|
||
|
|
if _, ok := a.creds["legacy"]; ok {
|
||
|
|
t.Error("MD5 entry should have been skipped")
|
||
|
|
}
|
||
|
|
|
||
|
|
cases := []struct {
|
||
|
|
user, pass string
|
||
|
|
want bool
|
||
|
|
}{
|
||
|
|
{"alice", "s3cret", true}, // $2y$ accepted
|
||
|
|
{"bob", "hunter2", true}, // $2a$ accepted
|
||
|
|
{"alice", "wrong", false}, // wrong password
|
||
|
|
{"bob", "s3cret", false}, // right password, wrong user
|
||
|
|
{"nobody", "s3cret", false}, // unknown user
|
||
|
|
{"legacy", "whatever", false},
|
||
|
|
}
|
||
|
|
for _, c := range cases {
|
||
|
|
if got := a.check(c.user, c.pass); got != c.want {
|
||
|
|
t.Errorf("check(%q, %q) = %v, want %v", c.user, c.pass, got, c.want)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestBasicAuthMiddleware(t *testing.T) {
|
||
|
|
a := &Authenticator{creds: map[string][]byte{"alice": []byte(bcryptHash(t, "s3cret"))}}
|
||
|
|
a.dummyHash, _ = bcrypt.GenerateFromPassword([]byte("dummy"), 10)
|
||
|
|
|
||
|
|
var reached bool
|
||
|
|
// Burst of 2 failures before throttling, so the 429 path is reachable.
|
||
|
|
limiter := rate.NewLimiter(rate.Every(time.Hour), 2)
|
||
|
|
h := a.basicAuth(limiter, func(w http.ResponseWriter, r *http.Request) {
|
||
|
|
reached = true
|
||
|
|
w.WriteHeader(http.StatusCreated)
|
||
|
|
})
|
||
|
|
|
||
|
|
do := func(user, pass string, withCreds bool) *httptest.ResponseRecorder {
|
||
|
|
reached = false
|
||
|
|
req := httptest.NewRequest(http.MethodPost, "/upload", nil)
|
||
|
|
if withCreds {
|
||
|
|
req.SetBasicAuth(user, pass)
|
||
|
|
}
|
||
|
|
rec := httptest.NewRecorder()
|
||
|
|
h(rec, req)
|
||
|
|
return rec
|
||
|
|
}
|
||
|
|
|
||
|
|
// Good credentials pass through.
|
||
|
|
if rec := do("alice", "s3cret", true); rec.Code != http.StatusCreated || !reached {
|
||
|
|
t.Fatalf("valid creds: code=%d reached=%v", rec.Code, reached)
|
||
|
|
}
|
||
|
|
|
||
|
|
// No credentials → 401 with a challenge.
|
||
|
|
rec := do("", "", false)
|
||
|
|
if rec.Code != http.StatusUnauthorized {
|
||
|
|
t.Fatalf("missing creds: code=%d, want 401", rec.Code)
|
||
|
|
}
|
||
|
|
if !strings.HasPrefix(rec.Header().Get("WWW-Authenticate"), "Basic ") {
|
||
|
|
t.Errorf("missing WWW-Authenticate challenge: %q", rec.Header().Get("WWW-Authenticate"))
|
||
|
|
}
|
||
|
|
if reached {
|
||
|
|
t.Error("handler reached despite missing creds")
|
||
|
|
}
|
||
|
|
|
||
|
|
// Wrong password burns the limiter (burst 2: one above + this one), then
|
||
|
|
// the next failure is throttled to 429.
|
||
|
|
if rec := do("alice", "nope", true); rec.Code != http.StatusUnauthorized {
|
||
|
|
t.Fatalf("wrong pass #2: code=%d, want 401", rec.Code)
|
||
|
|
}
|
||
|
|
if rec := do("alice", "nope", true); rec.Code != http.StatusTooManyRequests {
|
||
|
|
t.Fatalf("wrong pass #3: code=%d, want 429", rec.Code)
|
||
|
|
}
|
||
|
|
|
||
|
|
// A valid request still succeeds while failures are throttled — the
|
||
|
|
// limiter only gates failures.
|
||
|
|
if rec := do("alice", "s3cret", true); rec.Code != http.StatusCreated {
|
||
|
|
t.Fatalf("valid creds under throttle: code=%d, want 201", rec.Code)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestLoadHtpasswdErrors(t *testing.T) {
|
||
|
|
if _, err := LoadHtpasswd(filepath.Join(t.TempDir(), "does-not-exist")); err == nil {
|
||
|
|
t.Error("expected error for missing file")
|
||
|
|
}
|
||
|
|
|
||
|
|
empty := filepath.Join(t.TempDir(), "htpasswd")
|
||
|
|
if err := os.WriteFile(empty, []byte("# only comments\n\nlegacy:$apr1$x$y\n"), 0600); err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
if _, err := LoadHtpasswd(empty); err == nil {
|
||
|
|
t.Error("expected error when no bcrypt entries are usable")
|
||
|
|
}
|
||
|
|
}
|