feat(server): ghost-session guard — JWTs for deleted users stop authenticating
The JWT middleware validated signature + expiry and fabricated the request user purely from token claims, so a signature-valid token for a DELETED user kept full API access until token expiry — observed live on hardware (a phone session survived the test suite's user-table recreation and kept serving its true empty sets; open handoff bug 1). An ensureUserExistsMiddleware already existed but was only wired on the frontend SSR group (frontend.go) — every /api group used bare createJWTMiddleware. The existence check now lives INSIDE createJWTMiddleware's SuccessHandler so every protected group (SSR and API alike) gets it: - After claim validation, GetUser by the token's user_id; a missing row clears the token cookie and answers 401 user_not_found for API calls (the app's TokenAuthenticator then re-logins with stored credentials) or redirects to /login?session=invalid for page requests — same shape-aware split the error handler already uses. - Cost: one primary-key lookup per authenticated request (~0.2ms local); instant revocation, no TTL semantics. The redundant SSR-only middleware is deleted (its redirect behavior is preserved inside the guard). The guard exposed two processing-issues tests that were unknowingly RELYING on ghost semantics: they nested a second setupTestServer whose bootstrap deleted the first setup's regular user, then asserted 403 from the dead token. De-nested: a createLibraryDirect helper inserts the library under the live setup's user, no second bootstrap. New TestDeletedUserTokenRejected pins the contract: token works while the user exists -> 401 user_not_found after deletion. Full suite: 219 groups, zero failures.
This commit is contained in:
1 parent
4f05a232fe
commit
9cd6356892
5 files changed
+125
-57
No files matched your search
+32
-38
@@ -69,7 +69,7 @@ type Config struct {
|
||||
LoginTracker *ratelimit.LoginAttemptTracker
|
||||
ScannerHandler *handlers.Handler
|
||||
JobsHandler *handlers.JobsHandler
|
||||
SidecarHandler *handlers.SidecarHandler
|
||||
SidecarHandler *handlers.SidecarHandler
|
||||
ReaderHandler *handlers.ReaderHandler
|
||||
LibraryService *services.LibraryService
|
||||
}
|
||||
@@ -113,6 +113,31 @@ func createJWTMiddleware(cfg *Config) echo.MiddlewareFunc {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "invalid user ID in token")
|
||||
}
|
||||
|
||||
// Ghost-session guard: a signature-valid token for a user that
|
||||
// no longer exists must not authenticate (deleted users kept
|
||||
// full access until token expiry before this check). A
|
||||
// primary-key lookup per request — instant revocation, no TTL
|
||||
// semantics; the guard also covers the SSR groups that used to
|
||||
// chain ensureUserExistsMiddleware.
|
||||
if !userExists(cfg, userUUID) {
|
||||
log.Printf("Ghost session rejected: user %s no longer exists", userIDStr)
|
||||
c.SetCookie(&http.Cookie{
|
||||
Name: "token",
|
||||
Value: "",
|
||||
Expires: time.Now().Add(-24 * time.Hour),
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
if wantsHTML(c.Request().Header) {
|
||||
return c.Redirect(http.StatusFound, "/login?session=invalid")
|
||||
}
|
||||
return c.JSON(http.StatusUnauthorized, map[string]string{
|
||||
"error": "user_not_found",
|
||||
"message": "This account no longer exists. Please log in again.",
|
||||
})
|
||||
}
|
||||
|
||||
c.Set("user", database.Users{
|
||||
ID: pgtype.UUID{Bytes: userUUID, Valid: true},
|
||||
Email: claims["user_email"].(string),
|
||||
@@ -138,43 +163,12 @@ func createJWTMiddleware(cfg *Config) echo.MiddlewareFunc {
|
||||
})
|
||||
}
|
||||
|
||||
// ensureUserExistsMiddleware checks if the authenticated user still exists in the database
|
||||
func ensureUserExistsMiddleware(cfg *Config) echo.MiddlewareFunc {
|
||||
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(c *echo.Context) error {
|
||||
userIDStr, ok := c.Get("user_id").(string)
|
||||
if !ok {
|
||||
return next(c)
|
||||
}
|
||||
|
||||
userUUID, err := uuid.Parse(userIDStr)
|
||||
if err != nil {
|
||||
log.Printf("Invalid UUID in user existence check: %v", err)
|
||||
return next(c)
|
||||
}
|
||||
|
||||
// Check if user exists in database
|
||||
_, err = cfg.Queries.GetUser(c.Request().Context(), uuidToPGType(userUUID))
|
||||
if err != nil {
|
||||
log.Printf("User not found in database: %s", userIDStr)
|
||||
|
||||
// Clear invalid cookie
|
||||
c.SetCookie(&http.Cookie{
|
||||
Name: "token",
|
||||
Value: "",
|
||||
Expires: time.Now().Add(-24 * time.Hour),
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
|
||||
// Redirect to login with session=invalid
|
||||
return c.Redirect(http.StatusFound, "/login?session=invalid")
|
||||
}
|
||||
|
||||
return next(c)
|
||||
}
|
||||
}
|
||||
// userExists checks the users table for the token's claimed user. Runs on
|
||||
// every authenticated request: a primary-key lookup, cheap at this scale,
|
||||
// and it makes deletion revocable the moment it happens.
|
||||
func userExists(cfg *Config, id uuid.UUID) bool {
|
||||
_, err := cfg.Queries.GetUser(context.Background(), uuidToPGType(id))
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// wantsHTML determines if the request expects HTML response
|
||||
|
||||
Reference in new issue
Block a user