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.
This commit is contained in:
Reese Norris
2026-07-28 11:14:36 -04:00
parent 5e92fd511f
commit b229c06ed8
4 changed files with 196 additions and 3 deletions

View File

@@ -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 <access_token>
- `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.
---

View File

@@ -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)
}

View File

@@ -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)
)
}

View File

@@ -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 {