mirror of
https://github.com/renorris/openfsd
synced 2026-08-12 04:15:40 +08:00
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:
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user