diff --git a/docs/services/authentication.md b/docs/services/authentication.md index 34f34b5..c7fabd1 100644 --- a/docs/services/authentication.md +++ b/docs/services/authentication.md @@ -109,6 +109,33 @@ A guard that resolves more than a user, such as an API token record, implements Plugin routes that should accept an admin JWT or a personal API token call `cabana.ResolveBackend`. It reads only the Authorization Bearer header (never the admin cookie), applies the token's method filter, and for a scoped token requires a method-allowed scope that is full or matches every cover code you pass. On success it returns the request with `bouncer.WithUser` (and `bouncer.WithCredential` for a token). Failures are `cabana.ErrUnauthenticated` or `cabana.ErrTokenScope`. Routes registered before `cabana.Activate` must call it per request, because the resolver is published at activation. +```go src=modules/cabana/example_tokens_test.go#ExampleResolveBackend +handler := func(app *backpack.App, w http.ResponseWriter, r *http.Request) { + _, _, err := cabana.ResolveBackend(app, r, "acme.blog.*") + if err != nil { + if errors.Is(err, cabana.ErrTokenScope) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusForbidden) + fmt.Fprint(w, `{"error":"Insufficient permissions"}`) + return + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + fmt.Fprint(w, `{"error":"Authentication required"}`) + return + } + w.WriteHeader(http.StatusOK) +} + +rec := httptest.NewRecorder() +r := httptest.NewRequest(http.MethodPost, "/_blog/api/v1/posts", nil) +r.Header.Set("Authorization", "Bearer unused") +handler(nil, rec, r) +fmt.Println(rec.Code, rec.Body.String()) +// Output: +// 401 {"error":"Authentication required"} +``` + ## Passwords `bouncer.HashPassword` hashes with bcrypt at the cost you give it, `bouncer.CheckPassword` compares in constant time, and `bouncer.NeedsRehash` reports a hash made below your configured cost. WinterCMS stores bcrypt hashes too, so existing passwords keep working: diff --git a/modules/cabana/example_tokens_test.go b/modules/cabana/example_tokens_test.go new file mode 100644 index 0000000..0106704 --- /dev/null +++ b/modules/cabana/example_tokens_test.go @@ -0,0 +1,41 @@ +package cabana_test + +import ( + "errors" + "fmt" + "net/http" + "net/http/httptest" + + "git.golem15.com/golem15/summercms/modules/backpack" + "git.golem15.com/golem15/summercms/modules/cabana" +) + +// ExampleResolveBackend shows a plugin handler that accepts an admin JWT or +// personal API token through cabana.ResolveBackend. The resolver reads only +// the Authorization Bearer header (never the admin cookie). +func ExampleResolveBackend() { + handler := func(app *backpack.App, w http.ResponseWriter, r *http.Request) { + _, _, err := cabana.ResolveBackend(app, r, "acme.blog.*") + if err != nil { + if errors.Is(err, cabana.ErrTokenScope) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusForbidden) + fmt.Fprint(w, `{"error":"Insufficient permissions"}`) + return + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + fmt.Fprint(w, `{"error":"Authentication required"}`) + return + } + w.WriteHeader(http.StatusOK) + } + + rec := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodPost, "/_blog/api/v1/posts", nil) + r.Header.Set("Authorization", "Bearer unused") + handler(nil, rec, r) + fmt.Println(rec.Code, rec.Body.String()) + // Output: + // 401 {"error":"Authentication required"} +}