diff --git a/controllers/api/auth.go b/controllers/api/auth.go index dc8717c..da11cfc 100644 --- a/controllers/api/auth.go +++ b/controllers/api/auth.go @@ -1,7 +1,6 @@ package api import ( - "database/sql" "errors" "net/http" "strings" @@ -28,7 +27,7 @@ const ( permAccessOtherPosts = "golem15.journal.access_other_posts" permAccessPublish = "golem15.journal.access_publish" - backendJWTBlacklistTable = "backend_jwt_blacklist" + journalCover = "golem15.journal.*" ) func writeAPIError(w http.ResponseWriter, status int, message string) { @@ -55,55 +54,40 @@ func hasBearer(r *http.Request) bool { return ok && strings.TrimSpace(token) != "" } -func newBackendGuard(app *backpack.App, write func(http.ResponseWriter, error)) bouncer.Guard { - secret := "" - if app != nil && app.Config != nil { - secret = strings.TrimSpace(app.Config.String("admin.jwt.secret")) +// resolveError maps cabana.ResolveBackend errors to the PHP Journal API bodies. +func resolveError(w http.ResponseWriter, err error) { + if errors.Is(err, cabana.ErrTokenScope) { + writeAPIError(w, http.StatusForbidden, errInsufficientPerms) + return } - var gdb *gorm.DB - var sqlDB *sql.DB - var reg *cabana.Registry - if app != nil { - gdb, _ = app.Lookup[*gorm.DB]() - sqlDB, _ = app.Lookup[*sql.DB]() - reg, _ = app.Lookup[*cabana.Registry]() - if sqlDB == nil && gdb != nil { - if db, err := gdb.DB(); err == nil { - sqlDB = db - } - } - } - var bl bouncer.BlacklistStore - if sqlDB != nil { - bl = bouncer.NewPostgresBlacklist(sqlDB, backendJWTBlacklistTable) - } - return bouncer.NewBackendJWTGuard(secret, cabana.BackendUsers{DB: gdb, Registry: reg}, bl, write) + writeAuthRequired(w, err) } -// optionalBackendPrincipal verifies a backend JWT only when Authorization -// Bearer is present. Failure is anonymous: public GET must not 401. +// optionalBackendPrincipal verifies a backend JWT or personal API token only +// when Authorization Bearer is present. Failure is anonymous: public GET must +// not 401. Uses cabana.ResolveBackend with cover golem15.journal.*. func optionalBackendPrincipal(app *backpack.App, r *http.Request) *http.Request { if r == nil || !hasBearer(r) { return r } - pr, err := newBackendGuard(app, nil).Authenticate(r) - if err != nil || pr == nil { + out, _, err := cabana.ResolveBackend(app, r, journalCover) + if err != nil || out == nil { return r } - return r.WithContext(bouncer.WithUser(r.Context(), pr)) + return out } -// requireBackendPrincipal authenticates a backend-audience JWT and writes the -// PHP {error:"Authentication required"} body when the token is missing or -// invalid (D-15). It does not use cabana's admin unauthenticated envelope. +// requireBackendPrincipal authenticates a backend JWT or personal API token +// and writes the PHP {error:"Authentication required"} or +// {error:"Insufficient permissions"} body (D-15). It does not use cabana's +// admin unauthenticated envelope. func requireBackendPrincipal(app *backpack.App, w http.ResponseWriter, r *http.Request) (*http.Request, *bouncer.Principal, bool) { - pr, err := newBackendGuard(app, writeAuthRequired).Authenticate(r) + out, pr, err := cabana.ResolveBackend(app, r, journalCover) if err != nil || pr == nil { - writeAuthRequired(w, err) + resolveError(w, err) return r, nil, false } - r = r.WithContext(bouncer.WithUser(r.Context(), pr)) - return r, pr, true + return out, pr, true } func isEditor(pr *bouncer.Principal) bool { diff --git a/controllers/api/auth_test.go b/controllers/api/auth_test.go new file mode 100644 index 0000000..f3df98b --- /dev/null +++ b/controllers/api/auth_test.go @@ -0,0 +1,70 @@ +package api + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "git.golem15.com/golem15/summercms/modules/cabana" +) + +func TestResolveErrorMapping(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + err error + status int + body string + }{ + { + name: "ErrTokenScope", + err: cabana.ErrTokenScope, + status: http.StatusForbidden, + body: `{"error":"Insufficient permissions"}`, + }, + { + name: "wrapped ErrTokenScope", + err: fmt.Errorf("cover: %w", cabana.ErrTokenScope), + status: http.StatusForbidden, + body: `{"error":"Insufficient permissions"}`, + }, + { + name: "ErrUnauthenticated", + err: cabana.ErrUnauthenticated, + status: http.StatusUnauthorized, + body: `{"error":"Authentication required"}`, + }, + { + name: "generic error", + err: errors.New("boom"), + status: http.StatusUnauthorized, + body: `{"error":"Authentication required"}`, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + rec := httptest.NewRecorder() + resolveError(rec, tc.err) + if rec.Code != tc.status { + t.Fatalf("status = %d, want %d; body=%s", rec.Code, tc.status, rec.Body.String()) + } + got := rec.Body.Bytes() + var wantObj, gotObj map[string]any + if err := json.Unmarshal([]byte(tc.body), &wantObj); err != nil { + t.Fatal(err) + } + if err := json.Unmarshal(got, &gotObj); err != nil { + t.Fatalf("body json: %s err=%v", got, err) + } + wantRaw, _ := json.Marshal(wantObj) + gotRaw, _ := json.Marshal(gotObj) + if string(wantRaw) != string(gotRaw) { + t.Fatalf("body = %s, want %s", got, tc.body) + } + }) + } +} diff --git a/journal_api_token_smoke_test.go b/journal_api_token_smoke_test.go new file mode 100644 index 0000000..f785da5 --- /dev/null +++ b/journal_api_token_smoke_test.go @@ -0,0 +1,68 @@ +package journal_test + +import ( + "encoding/json" + "net/http" + "strings" + "testing" + + "git.golem15.com/golem15/sm-journal-plugin/controllers" + "git.golem15.com/golem15/sm-journal-plugin/models" + "git.golem15.com/golem15/summercms/modules/cabana" +) + +// TestJournalTokenTracer mints a journal-scoped personal API token through the +// admin account API and creates a Journal post as that token's owner (16.3-03). +func TestJournalTokenTracer(t *testing.T) { + env := newPostsEnv(t) + jwt := env.admin(t, controllers.PermissionAccessPosts) + adminID := env.lastUserID(t) + + create := env.call(t, http.MethodPost, adminAPI("/account/tokens"), jwt, map[string]any{ + "name": "journal-agent", + "expires": "90d", + "scopes": []string{"journal"}, + }) + if create.Code != http.StatusCreated { + t.Fatalf("create token: %d %s", create.Code, create.Body.String()) + } + var created struct { + Data cabana.AdminAPITokenCreated `json:"data"` + } + if err := json.Unmarshal(create.Body.Bytes(), &created); err != nil { + t.Fatal(err) + } + plain := created.Data.Token + if !strings.HasPrefix(plain, cabana.APITokenPrefix) || len(plain) != len(cabana.APITokenPrefix)+40 { + t.Fatalf("token shape %q", plain) + } + + postRec := env.call(t, http.MethodPost, "/_journal/api/v1/posts", plain, map[string]any{ + "title": "Token-authored post", + "content": "Written with a journal scope token", + }) + if postRec.Code != http.StatusCreated { + t.Fatalf("POST /posts with journal token: %d %s", postRec.Code, postRec.Body.String()) + } + var postBody struct { + Data struct { + ID uint `json:"id"` + UserID *uint `json:"user_id"` + Slug string `json:"slug"` + } `json:"data"` + } + if err := json.Unmarshal(postRec.Body.Bytes(), &postBody); err != nil { + t.Fatalf("post body: %s err=%v", postRec.Body.String(), err) + } + if postBody.Data.ID == 0 { + t.Fatalf("missing post id: %s", postRec.Body.String()) + } + + var row models.Post + if err := env.gdb.First(&row, postBody.Data.ID).Error; err != nil { + t.Fatal(err) + } + if row.UserID == nil || *row.UserID != adminID { + t.Fatalf("stored user_id = %v, want %d", row.UserID, adminID) + } +} diff --git a/lang/en/lang.yaml b/lang/en/lang.yaml index ece1f4f..8015594 100644 --- a/lang/en/lang.yaml +++ b/lang/en/lang.yaml @@ -131,6 +131,11 @@ tag: delete_confirm: Delete this tag? delete_success: Successfully deleted those tags. return_to_tags: Return to the journal tag list +scopes: + journal: Journal + journal_description: Create, edit, publish and delete posts as you, within your Journal permissions. + journal_read: Journal, read-only + journal_read_description: Read posts, including drafts you may see, without changing anything. editor: content_invalid: The content must be HTML text. image_invalid: The image must be a JPEG, PNG, GIF or WebP file. diff --git a/lang/pl/lang.yaml b/lang/pl/lang.yaml index 1be4249..4d894a4 100644 --- a/lang/pl/lang.yaml +++ b/lang/pl/lang.yaml @@ -83,6 +83,11 @@ category: delete_success: Kategorie zostały pomyślnie usunięte. return_to_categories: Wróć do listy kategorii reorder: Zmień kolejność kategorii +scopes: + journal: Journal + journal_description: Twórz, edytuj, publikuj i usuwaj posty jako Ty, w ramach swoich uprawnień Journal. + journal_read: Journal, tylko odczyt + journal_read_description: Czytaj posty, w tym szkice, które możesz zobaczyć, bez wprowadzania zmian. editor: content_invalid: Treść musi być tekstem HTML. image_invalid: Obraz musi być plikiem JPEG, PNG, GIF lub WebP. diff --git a/token_scopes.go b/token_scopes.go new file mode 100644 index 0000000..47cd51c --- /dev/null +++ b/token_scopes.go @@ -0,0 +1,26 @@ +package journal + +import "git.golem15.com/golem15/summercms/modules/pact" + +var _ pact.HasTokenScopes = (*Plugin)(nil) + +// TokenScopes registers personal API token scopes for the Journal plugin. +func (p *Plugin) TokenScopes() []pact.TokenScope { + all := []string{"GET", "HEAD", "POST", "PUT", "PATCH", "DELETE"} + return []pact.TokenScope{ + { + Name: "journal", + Label: "golem15.journal::lang.scopes.journal", + Description: "golem15.journal::lang.scopes.journal_description", + Permissions: []string{"golem15.journal.*"}, + Methods: all, + }, + { + Name: "journal:read", + Label: "golem15.journal::lang.scopes.journal_read", + Description: "golem15.journal::lang.scopes.journal_read_description", + Permissions: []string{"golem15.journal.*"}, + Methods: []string{"GET", "HEAD"}, + }, + } +}