From b229c06ed88f67a1a32c9d26fe6718bdd7c31d92 Mon Sep 17 00:00:00 2001 From: Reese Norris Date: Tue, 28 Jul 2026 11:14:36 -0400 Subject: [PATCH] web: revalidate Bearer actor against DB on API resource requests Overlay rating and identity from the database for Bearer tokens on dual-accept API groups so demotion and soft-delete take effect before write expansions. --- internal/web/README.md | 4 +- internal/web/api_v1_test.go | 151 ++++++++++++++++++++++++++++++++++++ internal/web/api_version.go | 5 +- internal/web/auth.go | 39 ++++++++++ 4 files changed, 196 insertions(+), 3 deletions(-) diff --git a/internal/web/README.md b/internal/web/README.md index db4c146..6c78255 100644 --- a/internal/web/README.md +++ b/internal/web/README.md @@ -44,7 +44,8 @@ The **airport editor** (`/airport-editor`) is a second complexity-gate exception - `POST /logout` clears the session cookie - Cookie-authenticated API mutations require a CSRF synchronizer token (`csrf_token` form field or `X-CSRF-Token` header matching the `openfsd_csrf` cookie) - Suspended/inactive ratings cannot open a web session (same as FSD policy) -- **Session cookies are revalidated against the DB on every use** (HTML + dual-accept API): missing or inactive/suspended certificates clear cookies and are rejected; claims (network rating + names) are overlaid from the DB so demotions take effect immediately. Residual window after soft-delete is **Bearer access tokens only** (15m TTL). +- **Session cookies are revalidated against the DB on every use** (HTML + dual-accept API): missing or inactive/suspended certificates clear cookies and are rejected; claims (network rating + names) are overlaid from the DB so demotions take effect immediately. +- **Bearer access tokens on dual-accept resource groups** (`/api/v1/user|config|fsdconn|sweatbox|editor/*`) are revalidated the same way (KD-18): demotion, suspension, and soft-delete take effect on the next request. Login/refresh/fsd-jwt remain credential-based and are outside this middleware. ### Cookie `Secure` flag (`COOKIE_SECURE`) | Condition | Secure | @@ -66,6 +67,7 @@ Authorization: Bearer - `createtoken` responses include additive `recommended_api_version`, `api_version_min`, and `api_version_max` so clients can pin the microversion header. - Bearer-authenticated clients do **not** need CSRF (CSRF applies only when the request is authenticated via the session cookie). - Dual-accept: a **valid** Bearer token wins over a session cookie; a garbage Bearer header does **not** disable CSRF if the session cookie is what authenticates the request. +- Bearer actors are **revalidated against the DB** on every protected resource request (rating/names overlay; inactive/deleted → 401). - **Operator automation:** prefer minted API tokens over `/auth/login` or `/auth/refresh`. Tokens are admin-equivalent until scopes exist—store as secrets; rotate on compromise via secret reset. --- diff --git a/internal/web/api_v1_test.go b/internal/web/api_v1_test.go index 18251ad..39a2267 100644 --- a/internal/web/api_v1_test.go +++ b/internal/web/api_v1_test.go @@ -486,3 +486,154 @@ func TestDataServersJSONUnauthenticated(t *testing.T) { assert.Contains(t, w.Body.String(), "OPENFSD") assert.Contains(t, w.Body.String(), "localhost") } + +// TestBearerActorRevalidation covers KD-18: Bearer tokens on dual-accept resource +// groups revalidate against the DB so demotion / soft-delete / hard-delete take effect. +func TestBearerActorRevalidation(t *testing.T) { + setRating := func(t *testing.T, env *testAPIEnv, cid, rating int) { + t.Helper() + u, err := env.server.dbRepo.UserRepo.GetUserByCID(cid) + require.NoError(t, err) + u.NetworkRating = rating + u.Password = "" + require.NoError(t, env.server.dbRepo.UserRepo.UpdateUser(u)) + } + + tests := []struct { + name string + mutate func(t *testing.T, env *testAPIEnv) + method string + path string + body any + wantStatus int + wantErrSubstr string + }{ + { + name: "valid_bearer_still_works", + method: http.MethodPost, + path: "/api/v1/user/load", + body: nil, // filled with self CID after login + wantStatus: http.StatusOK, + }, + { + name: "soft_deleted_inactive_401", + mutate: func(t *testing.T, env *testAPIEnv) { + setRating(t, env, env.admin.CID, int(protocol.NetworkRatingInactive)) + }, + method: http.MethodPost, + path: "/api/v1/user/load", + wantStatus: http.StatusUnauthorized, + wantErrSubstr: "unauthorized", + }, + { + name: "suspended_401", + mutate: func(t *testing.T, env *testAPIEnv) { + setRating(t, env, env.admin.CID, int(protocol.NetworkRatingSuspended)) + }, + method: http.MethodPost, + path: "/api/v1/user/load", + wantStatus: http.StatusUnauthorized, + wantErrSubstr: "unauthorized", + }, + { + name: "hard_deleted_401", + mutate: func(t *testing.T, env *testAPIEnv) { + require.NoError(t, env.server.dbRepo.UserRepo.DeleteUser(env.admin.CID)) + }, + method: http.MethodPost, + path: "/api/v1/user/load", + wantStatus: http.StatusUnauthorized, + wantErrSubstr: "unauthorized", + }, + { + name: "demoted_admin_config_forbidden", + mutate: func(t *testing.T, env *testAPIEnv) { + // Still active, but no longer Administrator — authz ceiling from DB overlay. + setRating(t, env, env.admin.CID, int(protocol.NetworkRatingObserver)) + }, + method: http.MethodGet, + path: "/api/v1/config/load", + body: nil, + wantStatus: http.StatusForbidden, + wantErrSubstr: "forbidden", + }, + { + name: "demoted_admin_can_load_self", + mutate: func(t *testing.T, env *testAPIEnv) { + setRating(t, env, env.admin.CID, int(protocol.NetworkRatingObserver)) + }, + method: http.MethodPost, + path: "/api/v1/user/load", + wantStatus: http.StatusOK, + }, + { + name: "demoted_admin_cannot_load_other", + mutate: func(t *testing.T, env *testAPIEnv) { + setRating(t, env, env.admin.CID, int(protocol.NetworkRatingObserver)) + }, + method: http.MethodPost, + path: "/api/v1/user/load", + // body set to observer CID in loop + wantStatus: http.StatusForbidden, + wantErrSubstr: "forbidden", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + env := setupTestAPI(t) + access, _ := env.login(t, env.admin.CID, env.adminPass) + + if tt.mutate != nil { + tt.mutate(t, env) + } + + body := tt.body + if tt.path == "/api/v1/user/load" && body == nil { + // Default: load self; demoted-cannot-load-other overrides to other CID. + cid := env.admin.CID + if tt.name == "demoted_admin_cannot_load_other" { + cid = env.observer.CID + } + body = map[string]any{"cid": cid} + } + + w := env.doJSON(t, tt.method, tt.path, body, access) + assert.Equal(t, tt.wantStatus, w.Code, w.Body.String()) + if tt.wantErrSubstr != "" { + res := decodeAPIV1(t, w) + require.NotNil(t, res.Err, w.Body.String()) + assert.Contains(t, *res.Err, tt.wantErrSubstr) + } + }) + } +} + +// TestBearerActorRevalidationSessionUnaffected ensures cookie dual-accept still works +// when revalidateBearerActor is on the chain (session path already revalidated; middleware skips). +func TestBearerActorRevalidationSessionUnaffected(t *testing.T) { + ts := newTestServer(t) + user := createTestUser(t, ts, "sess-ok1", int(protocol.NetworkRatingSupervisor)) + cookies := formLogin(t, ts, user.CID, "sess-ok1") + + // Refresh CSRF cookie from an authed HTML page (same pattern as TestAPICookieAuthRequiresCSRF). + req := httptest.NewRequest(http.MethodGet, "/dashboard", nil) + req.Header.Set("Cookie", cookieHeader(cookies)) + w := httptest.NewRecorder() + ts.engine.ServeHTTP(w, req) + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + cookies = mergeCookies(cookies, w.Result()) + csrf := csrfFromCookies(cookies) + require.NotEmpty(t, csrf, "csrf cookie after dashboard") + + req = httptest.NewRequest(http.MethodPost, "/api/v1/user/load", + strings.NewReader(fmt.Sprintf(`{"cid":%d}`, user.CID))) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Cookie", cookieHeader(cookies)) + req.Header.Set(csrfHeaderName, csrf) + w = httptest.NewRecorder() + ts.engine.ServeHTTP(w, req) + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + res := decodeAPIV1(t, w) + require.Nil(t, res.Err) +} diff --git a/internal/web/api_version.go b/internal/web/api_version.go index 4a27215..79d3dc6 100644 --- a/internal/web/api_version.go +++ b/internal/web/api_version.go @@ -117,13 +117,14 @@ func shapeFor[T any, D any](effective string, adapters []shapeAdapter[T, D], dom return adapters[0].shape(domain) } -// useAPIV1Protected attaches dual-accept auth + CSRF + API microversion middleware. -// PR-2 will add Bearer actor revalidation here. +// useAPIV1Protected attaches dual-accept auth + CSRF + API microversion + +// Bearer actor revalidation middleware (KD-18). func (s *Server) useAPIV1Protected(g *gin.RouterGroup) { g.Use( s.jwtBearerMiddleware, s.csrfIfCookieSession, s.apiVersionMiddleware, + s.revalidateBearerActor, // DB overlay / reject inactive for Bearer (KD-18) ) } diff --git a/internal/web/auth.go b/internal/web/auth.go index acd71de..063df66 100644 --- a/internal/web/auth.go +++ b/internal/web/auth.go @@ -299,6 +299,7 @@ const dbUserContextKey = "db_user" // inactive/suspended certificates, and overlays NetworkRating + names from the DB. // Handlers and requireMinRatingHTML keep reading claims.NetworkRating safely only // because this overlay is mandatory after session cookie parse. +// Also used by revalidateBearerActor for dual-accept Bearer resource requests (KD-18). func (s *Server) revalidateSessionFromDB(claims *auth.CustomClaims) (*auth.CustomClaims, *db.User, error) { user, err := s.dbRepo.UserRepo.GetUserByCID(claims.CID) if err != nil { @@ -323,6 +324,44 @@ func (s *Server) revalidateSessionFromDB(claims *auth.CustomClaims) (*auth.Custo return claims, user, nil } +// revalidateBearerActor loads the actor from the DB for Bearer-authenticated +// dual-accept API requests (KD-18). Missing / inactive / suspended → 401. +// Overlays NetworkRating + names so demotions take effect immediately. +// Session cookie path already revalidated in trySessionAuth — skipped here. +func (s *Server) revalidateBearerActor(c *gin.Context) { + method, _ := c.Get(authMethodContextKey) + if method != authMethodBearer { + c.Next() + return + } + + claims := getJwtContext(c) + if claims == nil { + res := newAPIV1Failure("unauthorized") + writeAPIV1Response(c, http.StatusUnauthorized, &res) + c.Abort() + return + } + + cid := claims.CID + claims, user, err := s.revalidateSessionFromDB(claims) + if err != nil { + slog.Debug("bearer actor revalidation rejected", + "cid", cid, + "event", "bearer_rejected_inactive", + "err", err.Error(), + ) + res := newAPIV1Failure("unauthorized") + writeAPIV1Response(c, http.StatusUnauthorized, &res) + c.Abort() + return + } + + setJwtContext(c, claims) + c.Set(dbUserContextKey, user) + c.Next() +} + // trySessionAuth parses the signed session cookie, revalidates against the DB, // and sets overlaid claims into the gin context (dual-accept API path). func (s *Server) trySessionAuth(c *gin.Context) bool {