diff --git a/modules/cabana/auth.go b/modules/cabana/auth.go index 06cb8a1..a09bd4d 100644 --- a/modules/cabana/auth.go +++ b/modules/cabana/auth.go @@ -9,6 +9,7 @@ import ( "net" "net/http" "strings" + "sync" "time" "git.golem15.com/golem15/summercms/modules/backpack" @@ -142,7 +143,7 @@ func (s *service) login(w http.ResponseWriter, r *http.Request) { WriteError(w, http.StatusInternalServerError, "error", msgServerError) return } - hash := dummyPasswordHash + hash := s.missingUserHash() if found && user.Password != "" { hash = user.Password } @@ -496,11 +497,23 @@ func adminIssuer(app *backpack.App, prefix string) string { return base + prefix + adminAPIVersion + "/auth/login" } -// dummyPasswordHash keeps a missing-user login on the bcrypt path. -var dummyPasswordHash = func() string { - hash, err := bouncer.HashPassword(10, "cabana-invalid-credentials") - if err != nil { - return "" +// missingHashes caches the unknown-login hash per bcrypt cost. +var missingHashes sync.Map + +// missingUserHash is the hash a login for an unknown (or ambiguous) identifier +// is checked against, so it costs the same bcrypt work as a real admin's. It is +// built once per cost at the service's configured cost, the cost stored hashes +// are rehashed to on login. +func (s *service) missingUserHash() string { + cost := s.bcryptCost + if cached, ok := missingHashes.Load(cost); ok { + return cached.(string) } - return hash -}() + hash, err := bouncer.HashPassword(cost, "cabana-invalid-credentials") + if err != nil { + // An unusable cost: fall back to the default, still on the bcrypt path. + hash, _ = bouncer.HashPassword(10, "cabana-invalid-credentials") + } + actual, _ := missingHashes.LoadOrStore(cost, hash) + return actual.(string) +} diff --git a/modules/cabana/auth_internal_test.go b/modules/cabana/auth_internal_test.go new file mode 100644 index 0000000..ef8a90a --- /dev/null +++ b/modules/cabana/auth_internal_test.go @@ -0,0 +1,24 @@ +package cabana + +import ( + "testing" + + "golang.org/x/crypto/bcrypt" +) + +// TestMissingUserHashUsesConfiguredCost pins WR-12: a login for an unknown +// identifier is checked against a hash at the configured bcrypt cost, so it +// costs the same as a real admin's (whose hash is rehashed to that cost). +func TestMissingUserHashUsesConfiguredCost(t *testing.T) { + for _, cost := range []int{4, 6} { + s := &service{bcryptCost: cost} + hash := s.missingUserHash() + got, err := bcrypt.Cost([]byte(hash)) + if err != nil || got != cost { + t.Fatalf("missing-user hash cost = %d, %v; want %d", got, err, cost) + } + if again := s.missingUserHash(); again != hash { + t.Fatal("missing-user hash was rebuilt instead of reused") + } + } +}