408 lines
12 KiB
Go
408 lines
12 KiB
Go
package auth
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/netip"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/iceBear67/simplepages/api"
|
|
"github.com/iceBear67/simplepages/internal/httpx"
|
|
"github.com/iceBear67/simplepages/internal/store"
|
|
)
|
|
|
|
// okHandler records that the request got past the middleware.
|
|
func okHandler(reached *bool) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if reached != nil {
|
|
*reached = true
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
})
|
|
}
|
|
|
|
func errorCode(t *testing.T, body []byte) api.Code {
|
|
t.Helper()
|
|
var env api.ErrorEnvelope
|
|
if err := json.Unmarshal(body, &env); err != nil {
|
|
t.Fatalf("response is not an error envelope: %v (%s)", err, body)
|
|
}
|
|
if env.Error.Code == "" {
|
|
t.Fatalf("envelope has no error code: %s", body)
|
|
}
|
|
return env.Error.Code
|
|
}
|
|
|
|
func newMiddleware(t *testing.T, db *store.DB, logTo *bytes.Buffer) *Middleware {
|
|
t.Helper()
|
|
var h slog.Handler = slog.NewTextHandler(logTo, &slog.HandlerOptions{Level: slog.LevelDebug})
|
|
log := slog.New(h)
|
|
return &Middleware{
|
|
V: NewVerifier(db, log, DefaultCacheTTL),
|
|
Limiter: NewLimiter(5, time.Minute, 128),
|
|
Trusted: []netip.Prefix{netip.MustParsePrefix("127.0.0.1/32")},
|
|
Log: log,
|
|
}
|
|
}
|
|
|
|
func TestAuthenticateAcceptsValidToken(t *testing.T) {
|
|
db := testStore(t)
|
|
m := newMiddleware(t, db, &bytes.Buffer{})
|
|
token, keyID := mintInto(t, db, store.ScopeAdmin, nil)
|
|
|
|
var gotID *Identity
|
|
h := m.Authenticate(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
id, ok := IdentityFrom(r.Context())
|
|
if !ok {
|
|
t.Error("no identity in context behind Authenticate")
|
|
}
|
|
gotID = id
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}))
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/whoami", nil)
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
rec := httptest.NewRecorder()
|
|
h.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusNoContent {
|
|
t.Fatalf("status = %d, want 204: %s", rec.Code, rec.Body)
|
|
}
|
|
if gotID == nil || gotID.KeyID != keyID {
|
|
t.Errorf("identity = %+v, want key %s", gotID, keyID)
|
|
}
|
|
}
|
|
|
|
func TestAuthenticateRejects(t *testing.T) {
|
|
db := testStore(t)
|
|
m := newMiddleware(t, db, &bytes.Buffer{})
|
|
token, keyID := mintInto(t, db, store.ScopeAdmin, nil)
|
|
_, secret, err := Parse(token)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
unknown, _, _, err := Mint()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
cases := []struct {
|
|
name string
|
|
header string
|
|
}{
|
|
{"no header", ""},
|
|
{"empty bearer", "Bearer "},
|
|
{"wrong scheme", "Basic " + token},
|
|
{"token without scheme", token},
|
|
{"malformed token", "Bearer not-a-token"},
|
|
{"unknown key", "Bearer " + unknown},
|
|
{"truncated secret", "Bearer " + Prefix + "_" + keyID + "_" + secret[:42]},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
reached := false
|
|
h := m.Authenticate(okHandler(&reached))
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/whoami", nil)
|
|
if tc.header != "" {
|
|
req.Header.Set("Authorization", tc.header)
|
|
}
|
|
rec := httptest.NewRecorder()
|
|
h.ServeHTTP(rec, req)
|
|
|
|
if reached {
|
|
t.Error("request reached the handler")
|
|
}
|
|
if rec.Code != http.StatusUnauthorized {
|
|
t.Fatalf("status = %d, want 401: %s", rec.Code, rec.Body)
|
|
}
|
|
if got := errorCode(t, rec.Body.Bytes()); got != api.CodeUnauthorized {
|
|
t.Errorf("code = %q, want %q", got, api.CodeUnauthorized)
|
|
}
|
|
if got := rec.Header().Get("WWW-Authenticate"); !strings.Contains(got, "Bearer") {
|
|
t.Errorf("WWW-Authenticate = %q", got)
|
|
}
|
|
// The client must not learn which check failed.
|
|
body := rec.Body.String()
|
|
for _, leak := range []string{"revoked", "expired", "unknown key", "secret mismatch"} {
|
|
if strings.Contains(strings.ToLower(body), leak) {
|
|
t.Errorf("response distinguishes the failure reason (%q): %s", leak, body)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// The scheme is case-insensitive per RFC 7235, and some CI clients send "bearer".
|
|
func TestAuthenticateAcceptsAnyCaseScheme(t *testing.T) {
|
|
db := testStore(t)
|
|
m := newMiddleware(t, db, &bytes.Buffer{})
|
|
token, _ := mintInto(t, db, store.ScopeAdmin, nil)
|
|
|
|
for _, scheme := range []string{"Bearer", "bearer", "BEARER", "BeArEr"} {
|
|
reached := false
|
|
h := m.Authenticate(okHandler(&reached))
|
|
req := httptest.NewRequest(http.MethodGet, "/x", nil)
|
|
req.Header.Set("Authorization", scheme+" "+token)
|
|
rec := httptest.NewRecorder()
|
|
h.ServeHTTP(rec, req)
|
|
if !reached {
|
|
t.Errorf("scheme %q rejected: %d %s", scheme, rec.Code, rec.Body)
|
|
}
|
|
}
|
|
}
|
|
|
|
// A token in the query string ends up in proxy logs and browser history, so it
|
|
// must never be accepted as a credential.
|
|
func TestTokenInQueryStringIsNotAccepted(t *testing.T) {
|
|
db := testStore(t)
|
|
m := newMiddleware(t, db, &bytes.Buffer{})
|
|
token, _ := mintInto(t, db, store.ScopeAdmin, nil)
|
|
|
|
reached := false
|
|
h := m.Authenticate(okHandler(&reached))
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/whoami?token="+token+"&access_token="+token, nil)
|
|
rec := httptest.NewRecorder()
|
|
h.ServeHTTP(rec, req)
|
|
|
|
if reached {
|
|
t.Fatal("a query-string token authenticated the request")
|
|
}
|
|
if rec.Code != http.StatusUnauthorized {
|
|
t.Errorf("status = %d, want 401", rec.Code)
|
|
}
|
|
}
|
|
|
|
// The load-bearing one: nothing this middleware logs may contain the secret.
|
|
func TestAuthLogsNeverContainCredentials(t *testing.T) {
|
|
db := testStore(t)
|
|
var logBuf bytes.Buffer
|
|
m := newMiddleware(t, db, &logBuf)
|
|
token, keyID := mintInto(t, db, store.ScopeAdmin, nil)
|
|
_, secret, err := Parse(token)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
handler := httpx.Chain(
|
|
m.Authenticate(okHandler(nil)),
|
|
httpx.WithRequestID(m.Trusted),
|
|
httpx.AccessLog(m.Log, m.Trusted),
|
|
httpx.Recover(m.Log),
|
|
)
|
|
|
|
// A successful request, a wrong-secret request, and a garbage request: the
|
|
// three paths that each touch the token.
|
|
for _, hdr := range []string{
|
|
"Bearer " + token,
|
|
"Bearer " + Prefix + "_" + keyID + "_" + strings.Repeat("z", 43),
|
|
"Bearer " + token + "trailing",
|
|
} {
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/whoami", nil)
|
|
req.Header.Set("Authorization", hdr)
|
|
req.RemoteAddr = "10.1.2.3:5555"
|
|
handler.ServeHTTP(httptest.NewRecorder(), req)
|
|
}
|
|
|
|
out := logBuf.String()
|
|
if out == "" {
|
|
t.Fatal("nothing was logged; the test would pass vacuously")
|
|
}
|
|
for _, forbidden := range []string{secret, token, "Bearer", "Authorization"} {
|
|
if strings.Contains(out, forbidden) {
|
|
t.Errorf("log contains %q:\n%s", forbidden, out)
|
|
}
|
|
}
|
|
// The public half is supposed to be there — otherwise an operator cannot
|
|
// tell which key made a request.
|
|
if !strings.Contains(out, keyID) {
|
|
t.Errorf("log does not record the key id:\n%s", out)
|
|
}
|
|
}
|
|
|
|
func TestAuthenticateRateLimitsFailures(t *testing.T) {
|
|
db := testStore(t)
|
|
m := newMiddleware(t, db, &bytes.Buffer{})
|
|
m.Limiter = NewLimiter(3, time.Minute, 32)
|
|
bad, _, _, err := Mint()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
good, _ := mintInto(t, db, store.ScopeAdmin, nil)
|
|
|
|
send := func(token, remote string) *httptest.ResponseRecorder {
|
|
h := m.Authenticate(okHandler(nil))
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/whoami", nil)
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
req.RemoteAddr = remote
|
|
rec := httptest.NewRecorder()
|
|
h.ServeHTTP(rec, req)
|
|
return rec
|
|
}
|
|
|
|
for i := 0; i < 3; i++ {
|
|
if got := send(bad, "10.1.2.3:5555").Code; got != http.StatusUnauthorized {
|
|
t.Fatalf("attempt %d: status = %d, want 401", i, got)
|
|
}
|
|
}
|
|
rec := send(bad, "10.1.2.3:5555")
|
|
if rec.Code != http.StatusTooManyRequests {
|
|
t.Fatalf("status = %d, want 429: %s", rec.Code, rec.Body)
|
|
}
|
|
if got := errorCode(t, rec.Body.Bytes()); got != api.CodeRateLimited {
|
|
t.Errorf("code = %q, want %q", got, api.CodeRateLimited)
|
|
}
|
|
if rec.Header().Get("Retry-After") == "" {
|
|
t.Error("429 without a Retry-After header")
|
|
}
|
|
|
|
// A different address is unaffected...
|
|
if got := send(bad, "10.9.9.9:5555").Code; got != http.StatusUnauthorized {
|
|
t.Errorf("unrelated client got %d, want 401", got)
|
|
}
|
|
// ...and a client that had never failed can still authenticate.
|
|
if got := send(good, "10.8.8.8:5555").Code; got != http.StatusNoContent {
|
|
t.Errorf("valid token from a clean client got %d, want 204", got)
|
|
}
|
|
}
|
|
|
|
func TestRequireAdmin(t *testing.T) {
|
|
db := testStore(t)
|
|
log := slog.New(slog.NewTextHandler(&bytes.Buffer{}, nil))
|
|
p := store.DefaultProject("demo")
|
|
if err := db.CreateProject(context.Background(), p); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
cases := []struct {
|
|
name string
|
|
identity *Identity
|
|
wantStatus int
|
|
}{
|
|
{"admin", &Identity{KeyID: "a", Scope: store.ScopeAdmin}, http.StatusNoContent},
|
|
{"project", &Identity{KeyID: "b", Scope: store.ScopeProject, ProjectID: &p.ID}, http.StatusForbidden},
|
|
{"none", nil, http.StatusUnauthorized},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
reached := false
|
|
h := RequireAdmin(log)(okHandler(&reached))
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/projects", nil)
|
|
if tc.identity != nil {
|
|
req = req.WithContext(ContextWithIdentity(req.Context(), tc.identity))
|
|
}
|
|
rec := httptest.NewRecorder()
|
|
h.ServeHTTP(rec, req)
|
|
if rec.Code != tc.wantStatus {
|
|
t.Errorf("status = %d, want %d: %s", rec.Code, tc.wantStatus, rec.Body)
|
|
}
|
|
if reached != (tc.wantStatus == http.StatusNoContent) {
|
|
t.Errorf("handler reached = %v", reached)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRequireProject(t *testing.T) {
|
|
db := testStore(t)
|
|
ctx := context.Background()
|
|
log := slog.New(slog.NewTextHandler(&bytes.Buffer{}, nil))
|
|
|
|
mine := store.DefaultProject("mine")
|
|
theirs := store.DefaultProject("theirs")
|
|
if err := db.CreateProject(ctx, mine); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := db.CreateProject(ctx, theirs); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
resolver := ResolverFunc(func(ctx context.Context, name string) (int64, error) {
|
|
p, err := db.ProjectByName(ctx, name)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return p.ID, nil
|
|
})
|
|
|
|
admin := &Identity{KeyID: "admin00000000000", Scope: store.ScopeAdmin}
|
|
owner := &Identity{KeyID: "owner00000000000", Scope: store.ScopeProject, ProjectID: &mine.ID}
|
|
|
|
cases := []struct {
|
|
name string
|
|
identity *Identity
|
|
project string
|
|
wantStatus int
|
|
}{
|
|
{"owner on own project", owner, "mine", http.StatusNoContent},
|
|
{"owner on another project", owner, "theirs", http.StatusForbidden},
|
|
{"admin on any project", admin, "theirs", http.StatusNoContent},
|
|
{"admin on unknown project", admin, "ghost", http.StatusNotFound},
|
|
// An unknown name must look exactly like someone else's project to a
|
|
// project-scoped key, or the API becomes a project-name oracle.
|
|
{"owner on unknown project", owner, "ghost", http.StatusForbidden},
|
|
{"no identity", nil, "mine", http.StatusUnauthorized},
|
|
{"missing name", owner, "", http.StatusBadRequest},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
reached := false
|
|
mux := http.NewServeMux()
|
|
mux.Handle("GET /api/v1/projects/{name}", RequireProject("name", resolver, log)(okHandler(&reached)))
|
|
// The "missing name" case cannot be produced through the mux, so it
|
|
// exercises the handler directly.
|
|
var h http.Handler = mux
|
|
target := "/api/v1/projects/" + tc.project
|
|
if tc.project == "" {
|
|
h = RequireProject("name", resolver, log)(okHandler(&reached))
|
|
target = "/api/v1/projects/"
|
|
}
|
|
|
|
req := httptest.NewRequest(http.MethodGet, target, nil)
|
|
if tc.identity != nil {
|
|
req = req.WithContext(ContextWithIdentity(req.Context(), tc.identity))
|
|
}
|
|
rec := httptest.NewRecorder()
|
|
h.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != tc.wantStatus {
|
|
t.Errorf("status = %d, want %d: %s", rec.Code, tc.wantStatus, rec.Body)
|
|
}
|
|
if reached != (tc.wantStatus == http.StatusNoContent) {
|
|
t.Errorf("handler reached = %v", reached)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// Ownership is decided on row ids. A project key must not gain access to a
|
|
// project just because a name resolves to it.
|
|
func TestOwnsComparesIDsNotNames(t *testing.T) {
|
|
one := int64(1)
|
|
two := int64(2)
|
|
cases := []struct {
|
|
name string
|
|
id *Identity
|
|
ask int64
|
|
want bool
|
|
}{
|
|
{"admin owns anything", &Identity{Scope: store.ScopeAdmin}, 42, true},
|
|
{"project owns itself", &Identity{Scope: store.ScopeProject, ProjectID: &one}, 1, true},
|
|
{"project does not own another", &Identity{Scope: store.ScopeProject, ProjectID: &two}, 1, false},
|
|
{"project key without a project owns nothing", &Identity{Scope: store.ScopeProject}, 1, false},
|
|
{"nil identity owns nothing", nil, 1, false},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
if got := tc.id.Owns(tc.ask); got != tc.want {
|
|
t.Errorf("Owns(%d) = %v, want %v", tc.ask, got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|