feat(10-01): serve the embedded admin SPA at backend.uri with cookie login
- backend.uri prefix (default /backend) mounts the admin API at {prefix}/api/v1
and the embedded SPA shell at {prefix} with an api/ JSON 404 fallback
- cookie transport: an X-Requested-With login sets the HttpOnly summer_admin
cookie and returns no token; the backend guard reads the cookie after Bearer
- CSRF wrapper refuses cookie-only POST/PUT/DELETE without X-Requested-With
- boardwalk package embeds boardwalk/dist, rewrites index.html once per prefix
and sets cache and security headers
- framework admin OpenAPI pipeline (swag, swagger2openapi, openapi-typescript)
with prefix-relative paths and typed envelopes for the tracer routes
- admin/ Vite SPA: login, plugin rail, section panel and read-only list
through the openapi-fetch client typed by the generated schema
This commit is contained in:
@@ -1,9 +1,19 @@
|
||||
package cabana
|
||||
|
||||
// Admin API annotations. swag reads these with the handler package so
|
||||
// docs/openapi.json lists every D-09 route. The functions are not mounted;
|
||||
// service.mount in http.go is the runtime route table, and
|
||||
// TestPhase09PermissionMatrix fails if the two lists diverge.
|
||||
// @title SummerCMS Admin API
|
||||
// @version 1
|
||||
// @description Framework admin API consumed by the embedded admin SPA. Every path is relative to {backend.uri}/api/v1 (for example /backend/api/v1). The SPA authenticates with the HttpOnly summer_admin cookie set by a login that sends X-Requested-With: XMLHttpRequest, and sends that header on every request; CLI clients and tests send the BackendBearer Authorization header instead.
|
||||
// @BasePath /
|
||||
// @securityDefinitions.apikey BackendBearer
|
||||
// @in header
|
||||
// @name Authorization
|
||||
// @description Backend admin bearer token. Send "Bearer {access_token}".
|
||||
|
||||
// Admin API annotations. scripts/check-admin-openapi.sh reads them with swag
|
||||
// to produce admin/openapi/admin.json, the document the SPA's TypeScript types
|
||||
// are generated from (D-15). The functions are not mounted; service.mount in
|
||||
// http.go is the runtime route table, and TestPhase09PermissionMatrix plus
|
||||
// TestPhase09ContractInventory fail if the two lists diverge.
|
||||
|
||||
// ErrorBody is one D-10 error object.
|
||||
type ErrorBody struct {
|
||||
@@ -32,41 +42,84 @@ type SuccessEnvelope struct {
|
||||
Meta SuccessMeta `json:"meta"`
|
||||
}
|
||||
|
||||
// AdminLoginData is the admin login payload.
|
||||
// AdminLoginData is the admin login and refresh payload. Bearer transport
|
||||
// carries access_token; cookie transport (X-Requested-With: XMLHttpRequest)
|
||||
// carries token_type "cookie" and expires_in, never the token.
|
||||
type AdminLoginData struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
AccessToken string `json:"access_token,omitempty"`
|
||||
TokenType string `json:"token_type"`
|
||||
ExpiresIn int `json:"expires_in,omitempty"`
|
||||
}
|
||||
|
||||
// AdminLoginEnvelope is the admin login success body.
|
||||
type AdminLoginEnvelope struct {
|
||||
Data AdminLoginData `json:"data"`
|
||||
Meta SuccessMeta `json:"meta"`
|
||||
// Envelope is the typed D-10 success envelope.
|
||||
type Envelope[T any] struct {
|
||||
Data T `json:"data"`
|
||||
Meta SuccessMeta `json:"meta"`
|
||||
}
|
||||
|
||||
// AdminLogin documents POST /_admin/api/v1/auth/login.
|
||||
// ListEnvelope is the typed D-10 paginated envelope (Phase 9 D-11 meta).
|
||||
type ListEnvelope[T any] struct {
|
||||
Data T `json:"data"`
|
||||
Meta ListMeta `json:"meta"`
|
||||
}
|
||||
|
||||
// AdminRecord is one admin record: a string-keyed map read through its
|
||||
// list or form schema (D-16).
|
||||
type AdminRecord map[string]any
|
||||
|
||||
// AdminLoginRequest is the admin login body. Either login or email
|
||||
// identifies the backend user.
|
||||
type AdminLoginRequest struct {
|
||||
Login string `json:"login,omitempty"`
|
||||
Email string `json:"email,omitempty"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
// AdminRoleSummary is the role attached to an admin profile.
|
||||
type AdminRoleSummary struct {
|
||||
ID uint `json:"id"`
|
||||
Code string `json:"code"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
// AdminProfile is the GET /auth/me payload.
|
||||
type AdminProfile struct {
|
||||
ID uint `json:"id"`
|
||||
Login string `json:"login"`
|
||||
Email string `json:"email"`
|
||||
FirstName string `json:"first_name"`
|
||||
LastName string `json:"last_name"`
|
||||
IsSuperuser bool `json:"is_superuser"`
|
||||
Role *AdminRoleSummary `json:"role,omitempty"`
|
||||
}
|
||||
|
||||
// AdminLogin documents POST /auth/login.
|
||||
//
|
||||
// @Summary Admin login
|
||||
// @Tags admin
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Success 200 {object} AdminLoginEnvelope
|
||||
// @Param body body AdminLoginRequest true "Credentials"
|
||||
// @Param X-Requested-With header string false "XMLHttpRequest selects cookie transport"
|
||||
// @Success 200 {object} Envelope[AdminLoginData]
|
||||
// @Failure 401 {object} ErrorEnvelope
|
||||
// @Router /_admin/api/v1/auth/login [post]
|
||||
// @Router /auth/login [post]
|
||||
func AdminLogin() {}
|
||||
|
||||
// AdminRefresh documents POST /_admin/api/v1/auth/refresh.
|
||||
// AdminRefresh documents POST /auth/refresh.
|
||||
//
|
||||
// @Summary Refresh an admin token
|
||||
// @Tags admin
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Success 200 {object} AdminLoginEnvelope
|
||||
// @Param X-Requested-With header string false "XMLHttpRequest; required unless a Bearer token is sent"
|
||||
// @Success 200 {object} Envelope[AdminLoginData]
|
||||
// @Failure 403 {object} ErrorEnvelope
|
||||
// @Failure 401 {object} ErrorEnvelope
|
||||
// @Router /_admin/api/v1/auth/refresh [post]
|
||||
// @Router /auth/refresh [post]
|
||||
func AdminRefresh() {}
|
||||
|
||||
// AdminLogout documents POST /_admin/api/v1/auth/logout.
|
||||
// AdminLogout documents POST /auth/logout.
|
||||
//
|
||||
// @Summary Admin logout
|
||||
// @Tags admin
|
||||
@@ -74,33 +127,33 @@ func AdminRefresh() {}
|
||||
// @Security BackendBearer
|
||||
// @Success 200 {object} SuccessEnvelope
|
||||
// @Failure 401 {object} ErrorEnvelope
|
||||
// @Router /_admin/api/v1/auth/logout [post]
|
||||
// @Router /auth/logout [post]
|
||||
func AdminLogout() {}
|
||||
|
||||
// AdminMe documents GET /_admin/api/v1/auth/me.
|
||||
// AdminMe documents GET /auth/me.
|
||||
//
|
||||
// @Summary Current admin
|
||||
// @Tags admin
|
||||
// @Produce json
|
||||
// @Security BackendBearer
|
||||
// @Success 200 {object} SuccessEnvelope
|
||||
// @Success 200 {object} Envelope[AdminProfile]
|
||||
// @Failure 401 {object} ErrorEnvelope
|
||||
// @Router /_admin/api/v1/auth/me [get]
|
||||
// @Router /auth/me [get]
|
||||
func AdminMe() {}
|
||||
|
||||
// AdminNavigation documents GET /_admin/api/v1/navigation.
|
||||
// AdminNavigation documents GET /navigation.
|
||||
//
|
||||
// @Summary Admin navigation
|
||||
// @Tags admin
|
||||
// @Produce json
|
||||
// @Security BackendBearer
|
||||
// @Success 200 {object} SuccessEnvelope
|
||||
// @Success 200 {object} Envelope[[]NavigationEntry]
|
||||
// @Failure 401 {object} ErrorEnvelope
|
||||
// @Failure 403 {object} ErrorEnvelope
|
||||
// @Router /_admin/api/v1/navigation [get]
|
||||
// @Router /navigation [get]
|
||||
func AdminNavigation() {}
|
||||
|
||||
// AdminSettingsList documents GET /_admin/api/v1/settings.
|
||||
// AdminSettingsList documents GET /settings.
|
||||
//
|
||||
// @Summary List admin settings
|
||||
// @Tags admin
|
||||
@@ -109,10 +162,10 @@ func AdminNavigation() {}
|
||||
// @Success 200 {object} SuccessEnvelope
|
||||
// @Failure 401 {object} ErrorEnvelope
|
||||
// @Failure 403 {object} ErrorEnvelope
|
||||
// @Router /_admin/api/v1/settings [get]
|
||||
// @Router /settings [get]
|
||||
func AdminSettingsList() {}
|
||||
|
||||
// AdminSettingsSchema documents GET /_admin/api/v1/settings/{code}/schema.
|
||||
// AdminSettingsSchema documents GET /settings/{code}/schema.
|
||||
//
|
||||
// @Summary Admin settings schema
|
||||
// @Tags admin
|
||||
@@ -123,10 +176,10 @@ func AdminSettingsList() {}
|
||||
// @Failure 401 {object} ErrorEnvelope
|
||||
// @Failure 403 {object} ErrorEnvelope
|
||||
// @Failure 404 {object} ErrorEnvelope
|
||||
// @Router /_admin/api/v1/settings/{code}/schema [get]
|
||||
// @Router /settings/{code}/schema [get]
|
||||
func AdminSettingsSchema() {}
|
||||
|
||||
// AdminSettingsGet documents GET /_admin/api/v1/settings/{code}.
|
||||
// AdminSettingsGet documents GET /settings/{code}.
|
||||
//
|
||||
// @Summary Read admin settings
|
||||
// @Tags admin
|
||||
@@ -137,10 +190,10 @@ func AdminSettingsSchema() {}
|
||||
// @Failure 401 {object} ErrorEnvelope
|
||||
// @Failure 403 {object} ErrorEnvelope
|
||||
// @Failure 404 {object} ErrorEnvelope
|
||||
// @Router /_admin/api/v1/settings/{code} [get]
|
||||
// @Router /settings/{code} [get]
|
||||
func AdminSettingsGet() {}
|
||||
|
||||
// AdminSettingsPut documents PUT /_admin/api/v1/settings/{code}.
|
||||
// AdminSettingsPut documents PUT /settings/{code}.
|
||||
//
|
||||
// @Summary Update admin settings
|
||||
// @Tags admin
|
||||
@@ -152,7 +205,7 @@ func AdminSettingsGet() {}
|
||||
// @Failure 401 {object} ErrorEnvelope
|
||||
// @Failure 403 {object} ErrorEnvelope
|
||||
// @Failure 422 {object} ErrorEnvelope
|
||||
// @Router /_admin/api/v1/settings/{code} [put]
|
||||
// @Router /settings/{code} [put]
|
||||
func AdminSettingsPut() {}
|
||||
|
||||
// AdminListSchema documents the list schema route.
|
||||
@@ -164,11 +217,11 @@ func AdminSettingsPut() {}
|
||||
// @Param vendor path string true "Vendor"
|
||||
// @Param plugin path string true "Plugin"
|
||||
// @Param controller path string true "Controller"
|
||||
// @Success 200 {object} SuccessEnvelope
|
||||
// @Success 200 {object} Envelope[ListSchema]
|
||||
// @Failure 401 {object} ErrorEnvelope
|
||||
// @Failure 403 {object} ErrorEnvelope
|
||||
// @Failure 404 {object} ErrorEnvelope
|
||||
// @Router /_admin/api/v1/{vendor}/{plugin}/{controller}/schema/list [get]
|
||||
// @Router /{vendor}/{plugin}/{controller}/schema/list [get]
|
||||
func AdminListSchema() {}
|
||||
|
||||
// AdminFormSchema documents the form schema route.
|
||||
@@ -184,7 +237,7 @@ func AdminListSchema() {}
|
||||
// @Failure 401 {object} ErrorEnvelope
|
||||
// @Failure 403 {object} ErrorEnvelope
|
||||
// @Failure 404 {object} ErrorEnvelope
|
||||
// @Router /_admin/api/v1/{vendor}/{plugin}/{controller}/schema/form [get]
|
||||
// @Router /{vendor}/{plugin}/{controller}/schema/form [get]
|
||||
func AdminFormSchema() {}
|
||||
|
||||
// AdminRelationSchema documents the relation schema route.
|
||||
@@ -201,7 +254,7 @@ func AdminFormSchema() {}
|
||||
// @Failure 401 {object} ErrorEnvelope
|
||||
// @Failure 403 {object} ErrorEnvelope
|
||||
// @Failure 404 {object} ErrorEnvelope
|
||||
// @Router /_admin/api/v1/{vendor}/{plugin}/{controller}/schema/relation/{name} [get]
|
||||
// @Router /{vendor}/{plugin}/{controller}/schema/relation/{name} [get]
|
||||
func AdminRelationSchema() {}
|
||||
|
||||
// AdminList documents the record list route.
|
||||
@@ -213,11 +266,16 @@ func AdminRelationSchema() {}
|
||||
// @Param vendor path string true "Vendor"
|
||||
// @Param plugin path string true "Plugin"
|
||||
// @Param controller path string true "Controller"
|
||||
// @Success 200 {object} SuccessEnvelope
|
||||
// @Param search query string false "Search term"
|
||||
// @Param sort query string false "Sort column"
|
||||
// @Param dir query string false "Sort direction (asc or desc)"
|
||||
// @Param page query integer false "Page"
|
||||
// @Param per_page query integer false "Records per page"
|
||||
// @Success 200 {object} ListEnvelope[[]AdminRecord]
|
||||
// @Failure 401 {object} ErrorEnvelope
|
||||
// @Failure 403 {object} ErrorEnvelope
|
||||
// @Failure 422 {object} ErrorEnvelope
|
||||
// @Router /_admin/api/v1/{vendor}/{plugin}/{controller} [get]
|
||||
// @Router /{vendor}/{plugin}/{controller} [get]
|
||||
func AdminList() {}
|
||||
|
||||
// AdminCreate documents the record create route.
|
||||
@@ -234,7 +292,7 @@ func AdminList() {}
|
||||
// @Failure 401 {object} ErrorEnvelope
|
||||
// @Failure 403 {object} ErrorEnvelope
|
||||
// @Failure 422 {object} ErrorEnvelope
|
||||
// @Router /_admin/api/v1/{vendor}/{plugin}/{controller} [post]
|
||||
// @Router /{vendor}/{plugin}/{controller} [post]
|
||||
func AdminCreate() {}
|
||||
|
||||
// AdminBulkDelete documents the bulk delete route.
|
||||
@@ -251,7 +309,7 @@ func AdminCreate() {}
|
||||
// @Failure 401 {object} ErrorEnvelope
|
||||
// @Failure 403 {object} ErrorEnvelope
|
||||
// @Failure 422 {object} ErrorEnvelope
|
||||
// @Router /_admin/api/v1/{vendor}/{plugin}/{controller}/bulk-delete [post]
|
||||
// @Router /{vendor}/{plugin}/{controller}/bulk-delete [post]
|
||||
func AdminBulkDelete() {}
|
||||
|
||||
// AdminShow documents the record show route.
|
||||
@@ -268,7 +326,7 @@ func AdminBulkDelete() {}
|
||||
// @Failure 401 {object} ErrorEnvelope
|
||||
// @Failure 403 {object} ErrorEnvelope
|
||||
// @Failure 404 {object} ErrorEnvelope
|
||||
// @Router /_admin/api/v1/{vendor}/{plugin}/{controller}/{id} [get]
|
||||
// @Router /{vendor}/{plugin}/{controller}/{id} [get]
|
||||
func AdminShow() {}
|
||||
|
||||
// AdminUpdate documents the record update route.
|
||||
@@ -286,7 +344,7 @@ func AdminShow() {}
|
||||
// @Failure 401 {object} ErrorEnvelope
|
||||
// @Failure 403 {object} ErrorEnvelope
|
||||
// @Failure 422 {object} ErrorEnvelope
|
||||
// @Router /_admin/api/v1/{vendor}/{plugin}/{controller}/{id} [put]
|
||||
// @Router /{vendor}/{plugin}/{controller}/{id} [put]
|
||||
func AdminUpdate() {}
|
||||
|
||||
// AdminDelete documents the record delete route.
|
||||
@@ -303,7 +361,7 @@ func AdminUpdate() {}
|
||||
// @Failure 401 {object} ErrorEnvelope
|
||||
// @Failure 403 {object} ErrorEnvelope
|
||||
// @Failure 404 {object} ErrorEnvelope
|
||||
// @Router /_admin/api/v1/{vendor}/{plugin}/{controller}/{id} [delete]
|
||||
// @Router /{vendor}/{plugin}/{controller}/{id} [delete]
|
||||
func AdminDelete() {}
|
||||
|
||||
// AdminRelationLinked documents the linked-relation route.
|
||||
@@ -320,7 +378,7 @@ func AdminDelete() {}
|
||||
// @Success 200 {object} SuccessEnvelope
|
||||
// @Failure 401 {object} ErrorEnvelope
|
||||
// @Failure 403 {object} ErrorEnvelope
|
||||
// @Router /_admin/api/v1/{vendor}/{plugin}/{controller}/{id}/relations/{name} [get]
|
||||
// @Router /{vendor}/{plugin}/{controller}/{id}/relations/{name} [get]
|
||||
func AdminRelationLinked() {}
|
||||
|
||||
// AdminRelationCandidates documents the relation candidate route.
|
||||
@@ -337,7 +395,7 @@ func AdminRelationLinked() {}
|
||||
// @Success 200 {object} SuccessEnvelope
|
||||
// @Failure 401 {object} ErrorEnvelope
|
||||
// @Failure 403 {object} ErrorEnvelope
|
||||
// @Router /_admin/api/v1/{vendor}/{plugin}/{controller}/{id}/relations/{name}/candidates [get]
|
||||
// @Router /{vendor}/{plugin}/{controller}/{id}/relations/{name}/candidates [get]
|
||||
func AdminRelationCandidates() {}
|
||||
|
||||
// AdminRelationLink documents the relation link route.
|
||||
@@ -356,7 +414,7 @@ func AdminRelationCandidates() {}
|
||||
// @Failure 401 {object} ErrorEnvelope
|
||||
// @Failure 403 {object} ErrorEnvelope
|
||||
// @Failure 422 {object} ErrorEnvelope
|
||||
// @Router /_admin/api/v1/{vendor}/{plugin}/{controller}/{id}/relations/{name}/link [post]
|
||||
// @Router /{vendor}/{plugin}/{controller}/{id}/relations/{name}/link [post]
|
||||
func AdminRelationLink() {}
|
||||
|
||||
// AdminRelationUnlink documents the relation unlink route.
|
||||
@@ -375,5 +433,5 @@ func AdminRelationLink() {}
|
||||
// @Failure 401 {object} ErrorEnvelope
|
||||
// @Failure 403 {object} ErrorEnvelope
|
||||
// @Failure 422 {object} ErrorEnvelope
|
||||
// @Router /_admin/api/v1/{vendor}/{plugin}/{controller}/{id}/relations/{name}/unlink [post]
|
||||
// @Router /{vendor}/{plugin}/{controller}/{id}/relations/{name}/unlink [post]
|
||||
func AdminRelationUnlink() {}
|
||||
|
||||
7
cabana/admin_paths_test.go
Normal file
7
cabana/admin_paths_test.go
Normal file
@@ -0,0 +1,7 @@
|
||||
package cabana
|
||||
|
||||
// adminAPI composes a full admin API path under the default prefix, so tests
|
||||
// follow the backend.uri scheme (D-03) instead of hardcoding it.
|
||||
func adminAPI(rel string) string {
|
||||
return DefaultAdminPrefix + adminAPIVersion + rel
|
||||
}
|
||||
@@ -172,12 +172,39 @@ func (s *service) login(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
s.logAuth(r, "success", user.ID)
|
||||
if isAjax(r) {
|
||||
// Cookie transport (D-19): the SPA never sees the token.
|
||||
s.writeSessionCookie(w, token)
|
||||
WriteData(w, http.StatusOK, cookieLoginData(s.ttl), map[string]any{})
|
||||
return
|
||||
}
|
||||
WriteData(w, http.StatusOK, map[string]string{
|
||||
"access_token": token,
|
||||
"token_type": "bearer",
|
||||
}, map[string]any{})
|
||||
}
|
||||
|
||||
// cookieLoginData is the login/refresh body under cookie transport: no token,
|
||||
// only its type and the access lifetime in seconds.
|
||||
func cookieLoginData(ttl time.Duration) AdminLoginData {
|
||||
return AdminLoginData{TokenType: "cookie", ExpiresIn: int(ttl / time.Second)}
|
||||
}
|
||||
|
||||
// writeSessionCookie sets the admin JWT cookie scoped to the admin prefix.
|
||||
// Max-Age is the refresh window, because refresh accepts an expired access
|
||||
// token until iat plus refresh_ttl.
|
||||
func (s *service) writeSessionCookie(w http.ResponseWriter, token string) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: AdminCookieName,
|
||||
Value: token,
|
||||
Path: s.adminPrefix(),
|
||||
MaxAge: int(s.refreshTTL / time.Second),
|
||||
HttpOnly: true,
|
||||
Secure: true,
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *service) refresh(w http.ResponseWriter, r *http.Request) {
|
||||
raw := bearerToken(r)
|
||||
if raw == "" {
|
||||
@@ -247,23 +274,19 @@ func (s *service) me(w http.ResponseWriter, r *http.Request) {
|
||||
WriteData(w, http.StatusOK, profileOf(user), map[string]any{})
|
||||
}
|
||||
|
||||
func profileOf(user BackendUser) map[string]any {
|
||||
data := map[string]any{
|
||||
"id": user.ID,
|
||||
"login": user.Login,
|
||||
"email": user.Email,
|
||||
"first_name": user.FirstName,
|
||||
"last_name": user.LastName,
|
||||
"is_superuser": user.IsSuperuser,
|
||||
func profileOf(user BackendUser) AdminProfile {
|
||||
profile := AdminProfile{
|
||||
ID: user.ID,
|
||||
Login: user.Login,
|
||||
Email: user.Email,
|
||||
FirstName: user.FirstName,
|
||||
LastName: user.LastName,
|
||||
IsSuperuser: user.IsSuperuser,
|
||||
}
|
||||
if user.Role.ID != 0 {
|
||||
data["role"] = map[string]any{
|
||||
"id": user.Role.ID,
|
||||
"code": user.Role.Code,
|
||||
"name": user.Role.Name,
|
||||
}
|
||||
profile.Role = &AdminRoleSummary{ID: user.Role.ID, Code: user.Role.Code, Name: user.Role.Name}
|
||||
}
|
||||
return data
|
||||
return profile
|
||||
}
|
||||
|
||||
func bearerToken(r *http.Request) string {
|
||||
@@ -402,15 +425,18 @@ func adminLoginWindow(app *backpack.App) (int, int) {
|
||||
return maxAttempts, decayMinutes
|
||||
}
|
||||
|
||||
func adminIssuer(app *backpack.App) string {
|
||||
// adminIssuer is app.url plus the admin API login path. JWT verification does
|
||||
// not check iss, so tokens minted under an earlier prefix stay valid until
|
||||
// they expire.
|
||||
func adminIssuer(app *backpack.App, prefix string) string {
|
||||
base := ""
|
||||
if app != nil && app.Config != nil {
|
||||
base = strings.TrimRight(strings.TrimSpace(app.Config.String("app.url")), "/")
|
||||
}
|
||||
if base == "" {
|
||||
return "/_admin/api/v1/auth/login"
|
||||
if prefix == "" {
|
||||
prefix = DefaultAdminPrefix
|
||||
}
|
||||
return base + "/_admin/api/v1/auth/login"
|
||||
return base + prefix + adminAPIVersion + "/auth/login"
|
||||
}
|
||||
|
||||
// dummyPasswordHash keeps a missing-user login on the bcrypt path.
|
||||
|
||||
@@ -42,7 +42,7 @@ func TestAdminAuthLifecycle(t *testing.T) {
|
||||
gdb := adminGorm(t)
|
||||
h := adminHandler(t, gdb, nil)
|
||||
user := insertAdmin(t, gdb, "life", "Life@Example.Test", adminTestPassword, true, false)
|
||||
login := postJSON(t, h, "/_admin/api/v1/auth/login", map[string]string{
|
||||
login := postJSON(t, h, adminAPI("/auth/login"), map[string]string{
|
||||
"login": "life",
|
||||
"password": adminTestPassword,
|
||||
})
|
||||
@@ -63,7 +63,7 @@ func TestAdminAuthLifecycle(t *testing.T) {
|
||||
if !stamped.Valid {
|
||||
t.Fatal("successful login did not stamp last_login")
|
||||
}
|
||||
byEmail := postJSON(t, h, "/_admin/api/v1/auth/login", map[string]string{
|
||||
byEmail := postJSON(t, h, adminAPI("/auth/login"), map[string]string{
|
||||
"email": "life@example.test",
|
||||
"password": adminTestPassword,
|
||||
})
|
||||
@@ -71,12 +71,12 @@ func TestAdminAuthLifecycle(t *testing.T) {
|
||||
t.Fatalf("email login status=%d body=%s", byEmail.Code, byEmail.Body.String())
|
||||
}
|
||||
emailToken := accessToken(t, byEmail.Body.Bytes())
|
||||
me := getAuth(t, h, "/_admin/api/v1/auth/me", emailToken)
|
||||
me := getAuth(t, h, adminAPI("/auth/me"), emailToken)
|
||||
if me.Code != http.StatusOK {
|
||||
t.Fatalf("me status=%d body=%s", me.Code, me.Body.String())
|
||||
}
|
||||
assertSafeProfile(t, me.Body.Bytes(), user)
|
||||
refreshed := postAuth(t, h, http.MethodPost, "/_admin/api/v1/auth/refresh", emailToken, nil)
|
||||
refreshed := postAuth(t, h, http.MethodPost, adminAPI("/auth/refresh"), emailToken, nil)
|
||||
if refreshed.Code != http.StatusOK {
|
||||
t.Fatalf("refresh status=%d body=%s", refreshed.Code, refreshed.Body.String())
|
||||
}
|
||||
@@ -87,7 +87,7 @@ func TestAdminAuthLifecycle(t *testing.T) {
|
||||
if jwtAudience(t, next) != "backend" {
|
||||
t.Fatal("refreshed token lost the backend audience")
|
||||
}
|
||||
oldMe := getAuth(t, h, "/_admin/api/v1/auth/me", emailToken)
|
||||
oldMe := getAuth(t, h, adminAPI("/auth/me"), emailToken)
|
||||
if oldMe.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("previous token after refresh status=%d body=%s", oldMe.Code, oldMe.Body.String())
|
||||
}
|
||||
@@ -98,14 +98,14 @@ func TestAdminAuthLifecycle(t *testing.T) {
|
||||
if blacklisted != 1 {
|
||||
t.Fatalf("previous jti blacklist rows=%d", blacklisted)
|
||||
}
|
||||
out := postAuth(t, h, http.MethodPost, "/_admin/api/v1/auth/logout", next, nil)
|
||||
out := postAuth(t, h, http.MethodPost, adminAPI("/auth/logout"), next, nil)
|
||||
if out.Code != http.StatusOK {
|
||||
t.Fatalf("logout status=%d body=%s", out.Code, out.Body.String())
|
||||
}
|
||||
if strings.Contains(out.Body.String(), next) {
|
||||
t.Fatal("logout body contains the token")
|
||||
}
|
||||
after := getAuth(t, h, "/_admin/api/v1/auth/me", next)
|
||||
after := getAuth(t, h, adminAPI("/auth/me"), next)
|
||||
if after.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("me after logout status=%d", after.Code)
|
||||
}
|
||||
@@ -113,7 +113,7 @@ func TestAdminAuthLifecycle(t *testing.T) {
|
||||
if err := gdb.Model(&cabana.BackendUser{}).Where("id = ?", user.ID).Update("tokens_valid_after", cutoff).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fresh := accessToken(t, postJSON(t, h, "/_admin/api/v1/auth/login", map[string]string{
|
||||
fresh := accessToken(t, postJSON(t, h, adminAPI("/auth/login"), map[string]string{
|
||||
"login": "life", "password": adminTestPassword,
|
||||
}).Body.Bytes())
|
||||
// Login mints after the cutoff, so this token is current. Move the cutoff
|
||||
@@ -121,7 +121,7 @@ func TestAdminAuthLifecycle(t *testing.T) {
|
||||
if err := gdb.Model(&cabana.BackendUser{}).Where("id = ?", user.ID).Update("tokens_valid_after", time.Now().Add(time.Hour)).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stale := getAuth(t, h, "/_admin/api/v1/auth/me", fresh)
|
||||
stale := getAuth(t, h, adminAPI("/auth/me"), fresh)
|
||||
if stale.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("stale principal status=%d body=%s", stale.Code, stale.Body.String())
|
||||
}
|
||||
@@ -131,9 +131,9 @@ func TestAdminInactive(t *testing.T) {
|
||||
gdb := adminGorm(t)
|
||||
h := adminHandler(t, gdb, nil)
|
||||
insertAdmin(t, gdb, "inactive", "inactive@example.test", adminTestPassword, false, false)
|
||||
unknown := postJSON(t, h, "/_admin/api/v1/auth/login", map[string]string{"login": "nobody", "password": adminTestPassword})
|
||||
wrong := postJSON(t, h, "/_admin/api/v1/auth/login", map[string]string{"login": "inactive", "password": "wrong-password"})
|
||||
right := postJSON(t, h, "/_admin/api/v1/auth/login", map[string]string{"login": "inactive", "password": adminTestPassword})
|
||||
unknown := postJSON(t, h, adminAPI("/auth/login"), map[string]string{"login": "nobody", "password": adminTestPassword})
|
||||
wrong := postJSON(t, h, adminAPI("/auth/login"), map[string]string{"login": "inactive", "password": "wrong-password"})
|
||||
right := postJSON(t, h, adminAPI("/auth/login"), map[string]string{"login": "inactive", "password": adminTestPassword})
|
||||
assertSameOpaque(t, unknown, wrong, right)
|
||||
var stamped sql.NullTime
|
||||
if err := gdb.Raw(`SELECT last_login FROM backend_users WHERE login = 'inactive'`).Scan(&stamped).Error; err != nil {
|
||||
@@ -148,8 +148,8 @@ func TestAdminDeleted(t *testing.T) {
|
||||
gdb := adminGorm(t)
|
||||
h := adminHandler(t, gdb, nil)
|
||||
insertAdmin(t, gdb, "deleted", "deleted@example.test", adminTestPassword, true, true)
|
||||
unknown := postJSON(t, h, "/_admin/api/v1/auth/login", map[string]string{"login": "nobody-else", "password": adminTestPassword})
|
||||
deleted := postJSON(t, h, "/_admin/api/v1/auth/login", map[string]string{"login": "deleted", "password": adminTestPassword})
|
||||
unknown := postJSON(t, h, adminAPI("/auth/login"), map[string]string{"login": "nobody-else", "password": adminTestPassword})
|
||||
deleted := postJSON(t, h, adminAPI("/auth/login"), map[string]string{"login": "deleted", "password": adminTestPassword})
|
||||
assertSameOpaque(t, unknown, deleted)
|
||||
if strings.Contains(strings.ToLower(deleted.Body.String()), "delet") {
|
||||
t.Fatalf("deleted login disclosed the account: %s", deleted.Body.String())
|
||||
@@ -160,14 +160,14 @@ func TestAdminBlacklist(t *testing.T) {
|
||||
gdb := adminGorm(t)
|
||||
h := adminHandler(t, gdb, nil)
|
||||
insertAdmin(t, gdb, "revoke", "revoke@example.test", adminTestPassword, true, false)
|
||||
token := accessToken(t, postJSON(t, h, "/_admin/api/v1/auth/login", map[string]string{
|
||||
token := accessToken(t, postJSON(t, h, adminAPI("/auth/login"), map[string]string{
|
||||
"login": "revoke", "password": adminTestPassword,
|
||||
}).Body.Bytes())
|
||||
out := postAuth(t, h, http.MethodPost, "/_admin/api/v1/auth/logout", token, nil)
|
||||
out := postAuth(t, h, http.MethodPost, adminAPI("/auth/logout"), token, nil)
|
||||
if out.Code != http.StatusOK {
|
||||
t.Fatalf("logout status=%d body=%s", out.Code, out.Body.String())
|
||||
}
|
||||
again := postAuth(t, h, http.MethodPost, "/_admin/api/v1/auth/refresh", token, nil)
|
||||
again := postAuth(t, h, http.MethodPost, adminAPI("/auth/refresh"), token, nil)
|
||||
if again.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("refresh after logout status=%d body=%s", again.Code, again.Body.String())
|
||||
}
|
||||
@@ -192,7 +192,7 @@ func TestAdminLoginThrottle(t *testing.T) {
|
||||
})
|
||||
var last *httptest.ResponseRecorder
|
||||
for i := 0; i < 3; i++ {
|
||||
last = postJSON(t, h, "/_admin/api/v1/auth/login", map[string]string{
|
||||
last = postJSON(t, h, adminAPI("/auth/login"), map[string]string{
|
||||
"login": "throttle-user", "password": adminTestPassword,
|
||||
})
|
||||
}
|
||||
@@ -217,16 +217,16 @@ func TestAdminAuthLogging(t *testing.T) {
|
||||
slog.SetDefault(slog.New(slog.NewJSONHandler(&buf, nil)))
|
||||
t.Cleanup(func() { slog.SetDefault(prev) })
|
||||
|
||||
ok := postJSON(t, h, "/_admin/api/v1/auth/login", map[string]string{"login": "logged", "password": adminTestPassword})
|
||||
ok := postJSON(t, h, adminAPI("/auth/login"), map[string]string{"login": "logged", "password": adminTestPassword})
|
||||
token := accessToken(t, ok.Body.Bytes())
|
||||
assertLog(t, &buf, "success", user.ID, adminTestPassword, token)
|
||||
buf.Reset()
|
||||
|
||||
postJSON(t, h, "/_admin/api/v1/auth/login", map[string]string{"login": "logged", "password": "not-the-password"})
|
||||
postJSON(t, h, adminAPI("/auth/login"), map[string]string{"login": "logged", "password": "not-the-password"})
|
||||
assertLog(t, &buf, "failed", user.ID, "not-the-password", "")
|
||||
buf.Reset()
|
||||
|
||||
postJSON(t, h, "/_admin/api/v1/auth/login", map[string]string{"login": "missing-logged", "password": "not-the-password"})
|
||||
postJSON(t, h, adminAPI("/auth/login"), map[string]string{"login": "missing-logged", "password": "not-the-password"})
|
||||
failedUnknown := buf.String()
|
||||
if !strings.Contains(failedUnknown, `"outcome":"failed"`) {
|
||||
t.Fatalf("unknown login log = %s", failedUnknown)
|
||||
@@ -236,7 +236,7 @@ func TestAdminAuthLogging(t *testing.T) {
|
||||
}
|
||||
buf.Reset()
|
||||
|
||||
denied := getAuth(t, h, "/_admin/api/v1/acme/demo/widgets", token)
|
||||
denied := getAuth(t, h, adminAPI("/acme/demo/widgets"), token)
|
||||
if denied.Code != http.StatusForbidden {
|
||||
t.Fatalf("denied status=%d body=%s", denied.Code, denied.Body.String())
|
||||
}
|
||||
@@ -551,3 +551,9 @@ func jwtClaims(t *testing.T, token string) map[string]any {
|
||||
func itoa(id uint) string {
|
||||
return strconv.FormatUint(uint64(id), 10)
|
||||
}
|
||||
|
||||
// adminAPI mirrors the internal helper in admin_paths_test.go for this
|
||||
// external test package: a full admin API path under the default prefix.
|
||||
func adminAPI(rel string) string {
|
||||
return cabana.DefaultAdminPrefix + "/api/v1" + rel
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ import (
|
||||
func TestBulkDeleteEmpty(t *testing.T) {
|
||||
cap := &captureRouter{}
|
||||
(&service{}).mount(cap)
|
||||
key := "POST /_admin/api/v1/{vendor}/{plugin}/{controller}/bulk-delete"
|
||||
key := "POST " + adminAPI("/{vendor}/{plugin}/{controller}/bulk-delete")
|
||||
mw, ok := cap.middleware[key]
|
||||
if !ok || !containsString(mw, "backend") {
|
||||
t.Fatalf("missing %s in %v", key, cap.routes)
|
||||
|
||||
@@ -107,7 +107,7 @@ func TestAdminResetPasswordCommand(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
guard := bouncer.NewBackendJWTGuard(adminTestSecret, cabana.BackendUsers{DB: gdb}, nil, nil)
|
||||
req := httptest.NewRequest(http.MethodGet, "/_admin/api/v1/auth/me", nil)
|
||||
req := httptest.NewRequest(http.MethodGet, adminAPI("/auth/me"), nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
if _, err := guard.Authenticate(req); err != nil {
|
||||
t.Fatalf("token before reset: %v", err)
|
||||
|
||||
@@ -104,10 +104,10 @@ func TestCRUDRecordRoutes(t *testing.T) {
|
||||
cap := &captureRouter{}
|
||||
(&service{}).mount(cap)
|
||||
for _, want := range []string{
|
||||
"POST /_admin/api/v1/{vendor}/{plugin}/{controller}",
|
||||
"GET /_admin/api/v1/{vendor}/{plugin}/{controller}/{id}",
|
||||
"PUT /_admin/api/v1/{vendor}/{plugin}/{controller}/{id}",
|
||||
"DELETE /_admin/api/v1/{vendor}/{plugin}/{controller}/{id}",
|
||||
"POST " + adminAPI("/{vendor}/{plugin}/{controller}"),
|
||||
"GET " + adminAPI("/{vendor}/{plugin}/{controller}/{id}"),
|
||||
"PUT " + adminAPI("/{vendor}/{plugin}/{controller}/{id}"),
|
||||
"DELETE " + adminAPI("/{vendor}/{plugin}/{controller}/{id}"),
|
||||
} {
|
||||
mw, ok := cap.middleware[want]
|
||||
if !ok {
|
||||
|
||||
45
cabana/csrf.go
Normal file
45
cabana/csrf.go
Normal file
@@ -0,0 +1,45 @@
|
||||
package cabana
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
// requestedWithHeader is the custom header the admin SPA sends on every
|
||||
// request. A cross-site form or navigation cannot set it, and a
|
||||
// cross-origin fetch that sets it needs a CORS preflight the admin API
|
||||
// never answers (D-19).
|
||||
requestedWithHeader = "X-Requested-With"
|
||||
requestedWithAjax = "XMLHttpRequest"
|
||||
)
|
||||
|
||||
// requireAjax refuses a state-changing admin request that is not
|
||||
// Bearer-authenticated and does not carry X-Requested-With: XMLHttpRequest.
|
||||
// It runs before the wrapped handler, so a refused request is never decoded,
|
||||
// never looks up a controller and never reaches the database. The response
|
||||
// uses the fixed D-10 code forbidden.
|
||||
func requireAjax(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if !csrfSafe(r) {
|
||||
WriteError(w, http.StatusForbidden, "forbidden", msgForbidden)
|
||||
return
|
||||
}
|
||||
next(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
func csrfSafe(r *http.Request) bool {
|
||||
switch r.Method {
|
||||
case http.MethodGet, http.MethodHead, http.MethodOptions:
|
||||
return true
|
||||
}
|
||||
if bearerToken(r) != "" {
|
||||
return true
|
||||
}
|
||||
return isAjax(r)
|
||||
}
|
||||
|
||||
func isAjax(r *http.Request) bool {
|
||||
return strings.TrimSpace(r.Header.Get(requestedWithHeader)) == requestedWithAjax
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"time"
|
||||
|
||||
"git.golem15.com/golem15/summercms/backpack"
|
||||
"git.golem15.com/golem15/summercms/boardwalk"
|
||||
"git.golem15.com/golem15/summercms/bouncer"
|
||||
"git.golem15.com/golem15/summercms/pact"
|
||||
"git.golem15.com/golem15/summercms/party"
|
||||
@@ -20,10 +21,12 @@ import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Routes is the raw admin API mounted by surf.BuildRouter.
|
||||
// Routes is the raw admin API and SPA mounted by surf.BuildRouter. Prefix is
|
||||
// the normalized backend.uri every admin route lives under.
|
||||
type Routes struct {
|
||||
Middleware pact.Middleware
|
||||
Mount func(r pact.Router)
|
||||
Prefix string
|
||||
}
|
||||
|
||||
type service struct {
|
||||
@@ -38,6 +41,21 @@ type service struct {
|
||||
loginDecay int
|
||||
issuer string
|
||||
bl bouncer.BlacklistStore
|
||||
prefix string
|
||||
spa http.Handler
|
||||
}
|
||||
|
||||
// adminPrefix returns the mount path; a zero service uses the default.
|
||||
func (s *service) adminPrefix() string {
|
||||
if s == nil || s.prefix == "" {
|
||||
return DefaultAdminPrefix
|
||||
}
|
||||
return s.prefix
|
||||
}
|
||||
|
||||
// apiBase is the admin API root: the prefix plus /api/v1 (D-03).
|
||||
func (s *service) apiBase() string {
|
||||
return s.adminPrefix() + adminAPIVersion
|
||||
}
|
||||
|
||||
// Activate compiles admin controllers and, when any exist, requires
|
||||
@@ -54,6 +72,10 @@ func Activate(app *backpack.App, plugins []party.Plugin) (*Routes, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
prefix, err := AdminPrefix(app)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
reg, err := compileRegistry(items)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -72,7 +94,7 @@ func Activate(app *backpack.App, plugins []party.Plugin) (*Routes, error) {
|
||||
}
|
||||
}
|
||||
bl := adminBlacklist(app)
|
||||
guard := bouncer.NewBackendJWTGuard(secret, lazyBackendUsers{app: app, reg: reg}, bl, writeUnauthenticated)
|
||||
guard := bouncer.NewBackendJWTGuard(secret, lazyBackendUsers{app: app, reg: reg}, bl, writeUnauthenticated, AdminCookieName)
|
||||
if _, err := guards.Middleware("backend"); err != nil {
|
||||
if err := guards.Register("summercms.cabana", "backend", guard); err != nil {
|
||||
return nil, err
|
||||
@@ -93,10 +115,31 @@ func Activate(app *backpack.App, plugins []party.Plugin) (*Routes, error) {
|
||||
bcryptCost: adminBcryptCost(app),
|
||||
loginMax: loginMax,
|
||||
loginDecay: loginDecay,
|
||||
issuer: adminIssuer(app),
|
||||
issuer: adminIssuer(app, prefix),
|
||||
bl: bl,
|
||||
prefix: prefix,
|
||||
}
|
||||
return &Routes{Middleware: mw, Mount: svc.mount}, nil
|
||||
spa, err := boardwalk.Handler(prefix, http.HandlerFunc(writeNotFound))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cabana: admin SPA: %w", err)
|
||||
}
|
||||
svc.spa = spa
|
||||
return &Routes{Middleware: mw, Mount: svc.mount, Prefix: prefix}, nil
|
||||
}
|
||||
|
||||
func writeNotFound(w http.ResponseWriter, _ *http.Request) {
|
||||
WriteError(w, http.StatusNotFound, "not_found", msgNotFound)
|
||||
}
|
||||
|
||||
// serveSPA answers GET {prefix} and GET {prefix}/{path...} from the embedded
|
||||
// build. API paths that no route matched fall through to it and receive the
|
||||
// D-10 not_found envelope, never index.html.
|
||||
func (s *service) serveSPA(w http.ResponseWriter, r *http.Request) {
|
||||
if s == nil || s.spa == nil {
|
||||
writeNotFound(w, r)
|
||||
return
|
||||
}
|
||||
s.spa.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
func writeUnauthenticated(w http.ResponseWriter, _ error) {
|
||||
@@ -121,12 +164,15 @@ func (p lazyBackendUsers) FindByID(ctx context.Context, id uint) (*bouncer.Princ
|
||||
|
||||
func (s *service) mount(r pact.Router) {
|
||||
throttle := fmt.Sprintf("throttle:%d,%d", s.loginMax, s.loginDecay)
|
||||
r.GroupRaw("/_admin/api/v1/auth", nil, func(g pact.Router) {
|
||||
api := s.apiBase()
|
||||
r.GroupRaw(api+"/auth", nil, func(g pact.Router) {
|
||||
// Login is exempt from the CSRF header: without it the response is a
|
||||
// Bearer body and no cookie is set, so a cross-site post gains nothing.
|
||||
g.Post("/login", s.login, throttle)
|
||||
g.Post("/refresh", s.refresh)
|
||||
g.Post("/refresh", requireAjax(s.refresh))
|
||||
})
|
||||
r.GroupRaw("/_admin/api/v1", []string{"backend"}, func(g pact.Router) {
|
||||
g.Post("/auth/logout", s.logout)
|
||||
r.GroupRaw(api, []string{"backend"}, func(g pact.Router) {
|
||||
g.Post("/auth/logout", requireAjax(s.logout))
|
||||
g.Get("/auth/me", s.me)
|
||||
g.Get("/navigation", s.navigation)
|
||||
g.Get("/settings", s.settingsList)
|
||||
@@ -134,7 +180,7 @@ func (s *service) mount(r pact.Router) {
|
||||
constrainSetting(g)
|
||||
g.Get("/settings/{code}", s.settingsGet)
|
||||
constrainSetting(g)
|
||||
g.Put("/settings/{code}", s.settingsPut)
|
||||
g.Put("/settings/{code}", requireAjax(s.settingsPut))
|
||||
constrainSetting(g)
|
||||
g.Get("/{vendor}/{plugin}/{controller}/schema/list", s.listSchema)
|
||||
constrainController(g)
|
||||
@@ -144,25 +190,31 @@ func (s *service) mount(r pact.Router) {
|
||||
constrainRelation(g)
|
||||
g.Get("/{vendor}/{plugin}/{controller}", s.list)
|
||||
constrainController(g)
|
||||
g.Post("/{vendor}/{plugin}/{controller}", s.create)
|
||||
g.Post("/{vendor}/{plugin}/{controller}", requireAjax(s.create))
|
||||
constrainController(g)
|
||||
g.Post("/{vendor}/{plugin}/{controller}/bulk-delete", s.bulkDelete)
|
||||
g.Post("/{vendor}/{plugin}/{controller}/bulk-delete", requireAjax(s.bulkDelete))
|
||||
constrainController(g)
|
||||
g.Get("/{vendor}/{plugin}/{controller}/{id}", s.show)
|
||||
constrainController(g)
|
||||
g.Put("/{vendor}/{plugin}/{controller}/{id}", s.update)
|
||||
g.Put("/{vendor}/{plugin}/{controller}/{id}", requireAjax(s.update))
|
||||
constrainController(g)
|
||||
g.Delete("/{vendor}/{plugin}/{controller}/{id}", s.deleteRecord)
|
||||
g.Delete("/{vendor}/{plugin}/{controller}/{id}", requireAjax(s.deleteRecord))
|
||||
constrainController(g)
|
||||
g.Get("/{vendor}/{plugin}/{controller}/{id}/relations/{name}", s.relationLinked)
|
||||
constrainRelation(g)
|
||||
g.Get("/{vendor}/{plugin}/{controller}/{id}/relations/{name}/candidates", s.relationCandidates)
|
||||
constrainRelation(g)
|
||||
g.Post("/{vendor}/{plugin}/{controller}/{id}/relations/{name}/link", s.relationLink)
|
||||
g.Post("/{vendor}/{plugin}/{controller}/{id}/relations/{name}/link", requireAjax(s.relationLink))
|
||||
constrainRelation(g)
|
||||
g.Post("/{vendor}/{plugin}/{controller}/{id}/relations/{name}/unlink", s.relationUnlink)
|
||||
g.Post("/{vendor}/{plugin}/{controller}/{id}/relations/{name}/unlink", requireAjax(s.relationUnlink))
|
||||
constrainRelation(g)
|
||||
})
|
||||
// The SPA shell: public, no guard. ServeMux prefers every API pattern
|
||||
// above over the {path...} wildcard.
|
||||
r.GroupRaw(s.adminPrefix(), nil, func(g pact.Router) {
|
||||
g.Get("", s.serveSPA)
|
||||
g.Get("/{path...}", s.serveSPA)
|
||||
})
|
||||
}
|
||||
|
||||
func constrainController(g pact.Router) {
|
||||
|
||||
@@ -9,14 +9,15 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestPhase09ContractInventory fails when the committed OpenAPI document
|
||||
// drops a D-09 route or a protected route's 401 response.
|
||||
// TestPhase09ContractInventory fails when the committed framework admin
|
||||
// OpenAPI document (admin/openapi/admin.json, D-15) drops an admin API route
|
||||
// or a protected route's 401 response. Paths are prefix-relative (D-03).
|
||||
func TestPhase09ContractInventory(t *testing.T) {
|
||||
_, file, _, ok := runtime.Caller(0)
|
||||
if !ok {
|
||||
t.Fatal("caller")
|
||||
}
|
||||
specPath := filepath.Clean(filepath.Join(filepath.Dir(file), "..", "..", "fonoteka.go", "docs", "openapi.json"))
|
||||
specPath := filepath.Clean(filepath.Join(filepath.Dir(file), "..", "admin", "openapi", "admin.json"))
|
||||
raw, err := os.ReadFile(specPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read %s: %v", specPath, err)
|
||||
@@ -37,11 +38,16 @@ func TestPhase09ContractInventory(t *testing.T) {
|
||||
t.Fatal("openapi is missing the BackendBearer scheme")
|
||||
}
|
||||
public := map[string]bool{
|
||||
"POST /_admin/api/v1/auth/login": true,
|
||||
"POST /_admin/api/v1/auth/refresh": true,
|
||||
"POST /auth/login": true,
|
||||
"POST /auth/refresh": true,
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
apiRoutes := 0
|
||||
for _, route := range phase09Routes {
|
||||
if route.spa {
|
||||
continue
|
||||
}
|
||||
apiRoutes++
|
||||
method, path, ok := splitRoute(route.key)
|
||||
if !ok {
|
||||
t.Fatalf("bad route key %s", route.key)
|
||||
@@ -76,8 +82,11 @@ func TestPhase09ContractInventory(t *testing.T) {
|
||||
t.Fatalf("%s has no 401 response", key)
|
||||
}
|
||||
}
|
||||
if len(seen) != len(phase09Routes) {
|
||||
t.Fatalf("contract routes=%d want %d", len(seen), len(phase09Routes))
|
||||
if len(seen) != apiRoutes {
|
||||
t.Fatalf("contract routes=%d want %d", len(seen), apiRoutes)
|
||||
}
|
||||
if len(spec.Paths) != len(pathsOf(phase09Routes)) {
|
||||
t.Fatalf("openapi lists %d paths, the mounted API has %d", len(spec.Paths), len(pathsOf(phase09Routes)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,3 +98,20 @@ func splitRoute(key string) (method, path string, ok bool) {
|
||||
}
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
func pathsOf(routes []struct {
|
||||
key string
|
||||
public bool
|
||||
spa bool
|
||||
}) map[string]bool {
|
||||
out := map[string]bool{}
|
||||
for _, route := range routes {
|
||||
if route.spa {
|
||||
continue
|
||||
}
|
||||
if _, path, ok := splitRoute(route.key); ok {
|
||||
out[path] = true
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
49
cabana/prefix.go
Normal file
49
cabana/prefix.go
Normal file
@@ -0,0 +1,49 @@
|
||||
package cabana
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"git.golem15.com/golem15/summercms/backpack"
|
||||
)
|
||||
|
||||
// DefaultAdminPrefix is the admin mount path when backend.uri is unset.
|
||||
// It matches WinterCMS's backendUri default.
|
||||
const DefaultAdminPrefix = "/backend"
|
||||
|
||||
// AdminCookieName carries the admin JWT for the embedded SPA (D-19).
|
||||
const AdminCookieName = "summer_admin"
|
||||
|
||||
// adminAPIVersion is appended to the prefix for every admin API route.
|
||||
const adminAPIVersion = "/api/v1"
|
||||
|
||||
var adminPrefixPattern = regexp.MustCompile(`^(/[a-z0-9][a-z0-9_-]*)+$`)
|
||||
|
||||
// AdminPrefix reads backend.uri and returns the normalized admin mount path.
|
||||
// Spaces are trimmed, a leading slash is added and trailing slashes are
|
||||
// removed; an empty value falls back to DefaultAdminPrefix. Every segment
|
||||
// must be lowercase letters, digits, '-' or '_' and start with a letter or
|
||||
// digit, so "/" alone, uppercase, spaces and dot segments are rejected.
|
||||
func AdminPrefix(app *backpack.App) (string, error) {
|
||||
raw := ""
|
||||
if app != nil && app.Config != nil {
|
||||
raw = app.Config.String("backend.uri")
|
||||
}
|
||||
return normalizeAdminPrefix(raw)
|
||||
}
|
||||
|
||||
func normalizeAdminPrefix(raw string) (string, error) {
|
||||
value := strings.TrimSpace(raw)
|
||||
if value == "" {
|
||||
return DefaultAdminPrefix, nil
|
||||
}
|
||||
if !strings.HasPrefix(value, "/") {
|
||||
value = "/" + value
|
||||
}
|
||||
value = strings.TrimRight(value, "/")
|
||||
if value == "" || !adminPrefixPattern.MatchString(value) {
|
||||
return "", fmt.Errorf("cabana: backend.uri %q is invalid: use one or more lowercase path segments such as /backend (set SUMMER_BACKEND__URI)", raw)
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
@@ -12,35 +12,49 @@ import (
|
||||
"git.golem15.com/golem15/summercms/pact"
|
||||
)
|
||||
|
||||
// phase09Routes is the D-09 admin surface mounted by service.mount.
|
||||
// A handler added outside this set, or a protected handler missing the
|
||||
// backend guard, fails TestPhase09PermissionMatrix.
|
||||
// phase09Routes is the admin surface mounted by service.mount. API keys are
|
||||
// method plus the path relative to {backend.uri}/api/v1 (D-03); spa entries
|
||||
// are the public SPA shell routes relative to {backend.uri} and are not part
|
||||
// of the OpenAPI inventory. A handler added outside this set, or a protected
|
||||
// handler missing the backend guard, fails TestPhase09PermissionMatrix.
|
||||
var phase09Routes = []struct {
|
||||
key string
|
||||
public bool
|
||||
spa bool
|
||||
}{
|
||||
{"POST /_admin/api/v1/auth/login", true},
|
||||
{"POST /_admin/api/v1/auth/refresh", true},
|
||||
{"POST /_admin/api/v1/auth/logout", false},
|
||||
{"GET /_admin/api/v1/auth/me", false},
|
||||
{"GET /_admin/api/v1/navigation", false},
|
||||
{"GET /_admin/api/v1/settings", false},
|
||||
{"GET /_admin/api/v1/settings/{code}/schema", false},
|
||||
{"GET /_admin/api/v1/settings/{code}", false},
|
||||
{"PUT /_admin/api/v1/settings/{code}", false},
|
||||
{"GET /_admin/api/v1/{vendor}/{plugin}/{controller}/schema/list", false},
|
||||
{"GET /_admin/api/v1/{vendor}/{plugin}/{controller}/schema/form", false},
|
||||
{"GET /_admin/api/v1/{vendor}/{plugin}/{controller}/schema/relation/{name}", false},
|
||||
{"GET /_admin/api/v1/{vendor}/{plugin}/{controller}", false},
|
||||
{"POST /_admin/api/v1/{vendor}/{plugin}/{controller}", false},
|
||||
{"POST /_admin/api/v1/{vendor}/{plugin}/{controller}/bulk-delete", false},
|
||||
{"GET /_admin/api/v1/{vendor}/{plugin}/{controller}/{id}", false},
|
||||
{"PUT /_admin/api/v1/{vendor}/{plugin}/{controller}/{id}", false},
|
||||
{"DELETE /_admin/api/v1/{vendor}/{plugin}/{controller}/{id}", false},
|
||||
{"GET /_admin/api/v1/{vendor}/{plugin}/{controller}/{id}/relations/{name}", false},
|
||||
{"GET /_admin/api/v1/{vendor}/{plugin}/{controller}/{id}/relations/{name}/candidates", false},
|
||||
{"POST /_admin/api/v1/{vendor}/{plugin}/{controller}/{id}/relations/{name}/link", false},
|
||||
{"POST /_admin/api/v1/{vendor}/{plugin}/{controller}/{id}/relations/{name}/unlink", false},
|
||||
{"POST /auth/login", true, false},
|
||||
{"POST /auth/refresh", true, false},
|
||||
{"POST /auth/logout", false, false},
|
||||
{"GET /auth/me", false, false},
|
||||
{"GET /navigation", false, false},
|
||||
{"GET /settings", false, false},
|
||||
{"GET /settings/{code}/schema", false, false},
|
||||
{"GET /settings/{code}", false, false},
|
||||
{"PUT /settings/{code}", false, false},
|
||||
{"GET /{vendor}/{plugin}/{controller}/schema/list", false, false},
|
||||
{"GET /{vendor}/{plugin}/{controller}/schema/form", false, false},
|
||||
{"GET /{vendor}/{plugin}/{controller}/schema/relation/{name}", false, false},
|
||||
{"GET /{vendor}/{plugin}/{controller}", false, false},
|
||||
{"POST /{vendor}/{plugin}/{controller}", false, false},
|
||||
{"POST /{vendor}/{plugin}/{controller}/bulk-delete", false, false},
|
||||
{"GET /{vendor}/{plugin}/{controller}/{id}", false, false},
|
||||
{"PUT /{vendor}/{plugin}/{controller}/{id}", false, false},
|
||||
{"DELETE /{vendor}/{plugin}/{controller}/{id}", false, false},
|
||||
{"GET /{vendor}/{plugin}/{controller}/{id}/relations/{name}", false, false},
|
||||
{"GET /{vendor}/{plugin}/{controller}/{id}/relations/{name}/candidates", false, false},
|
||||
{"POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/link", false, false},
|
||||
{"POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/unlink", false, false},
|
||||
{"GET ", true, true},
|
||||
{"GET /{path...}", true, true},
|
||||
}
|
||||
|
||||
// mountedKey is the full mounted route key for an inventory entry.
|
||||
func mountedKey(key string, spa bool) string {
|
||||
method, rel, _ := strings.Cut(key, " ")
|
||||
if spa {
|
||||
return method + " " + DefaultAdminPrefix + rel
|
||||
}
|
||||
return method + " " + adminAPI(rel)
|
||||
}
|
||||
|
||||
func TestPhase09PermissionMatrix(t *testing.T) {
|
||||
@@ -57,9 +71,10 @@ func TestPhase09PermissionMatrix(t *testing.T) {
|
||||
t.Fatalf("mounted %d admin routes, want %d: %#v", len(got), len(phase09Routes), router.routes)
|
||||
}
|
||||
for _, route := range phase09Routes {
|
||||
mw, ok := got[route.key]
|
||||
key := mountedKey(route.key, route.spa)
|
||||
mw, ok := got[key]
|
||||
if !ok {
|
||||
t.Fatalf("missing mounted route %s", route.key)
|
||||
t.Fatalf("missing mounted route %s in %v", key, router.routes)
|
||||
}
|
||||
hasBackend := false
|
||||
for _, name := range mw {
|
||||
@@ -257,7 +272,7 @@ func phase09DeniedService() *service {
|
||||
}
|
||||
|
||||
func phase09Request(principal *bouncer.Principal) *http.Request {
|
||||
req := httptest.NewRequest(http.MethodPost, "/_admin/api/v1/acme/demo/widgets/1/relations/editors/link", strings.NewReader(`{}`))
|
||||
req := httptest.NewRequest(http.MethodPost, adminAPI("/acme/demo/widgets/1/relations/editors/link"), strings.NewReader(`{}`))
|
||||
req.SetPathValue("vendor", "acme")
|
||||
req.SetPathValue("plugin", "demo")
|
||||
req.SetPathValue("controller", "widgets")
|
||||
|
||||
@@ -77,7 +77,7 @@ func TestSecretRedaction(t *testing.T) {
|
||||
}
|
||||
|
||||
func controllerRequest(principal *bouncer.Principal) *http.Request {
|
||||
req := httptest.NewRequest(http.MethodGet, "/_admin/api/v1/acme/demo/widgets", nil)
|
||||
req := httptest.NewRequest(http.MethodGet, adminAPI("/acme/demo/widgets"), nil)
|
||||
req.SetPathValue("vendor", "acme")
|
||||
req.SetPathValue("plugin", "demo")
|
||||
req.SetPathValue("controller", "widgets")
|
||||
|
||||
Reference in New Issue
Block a user