Files
vikingowl dee4cee23c fix(auth): invalidate valkey cache on session revoke
RevokeSession, RevokeSessionsByFamilyID, DeleteUserSessions,
RevokeOtherSessions, and ConsumeRefreshToken updated revoked_at in
Postgres but did not invalidate the valkey access-token cache. The cache
serves the original Session JSON (RevokedAt: null) until its TTL expires
(JWT_ACCESS_TTL = 2h), so logout / admin-revoke / refresh-reuse-detection
took up to 2h to actually invalidate.

Fix: each revocation path now uses RETURNING access_token_hash and DELs
the cache key via new helper invalidateCachedSessions. revokeBulk handles
multi-row revocations.

Adds three router-level negative tests for the admin auth chain
(RequireAuth + RequireRole("admin")):
- TestAdminChain_UserRole_Returns403 — user role rejected with 403
- TestAdminChain_AdminRole_Passes — admin role accepted
- TestAdminChain_NoBearerToken_Returns401 — missing token rejected with
  401 (auth runs before role check)

Repository-level regression test for the cache invalidation requires
real Valkey + Postgres, currently not in test harness — flagged as TODO
in planning/18-security-threat-model.md.

Audit findings H1, E (negative tests for session validation, authz).
2026-04-30 22:12:09 +02:00

251 lines
6.8 KiB
Go

package middleware_test
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"marktvogt.de/backend/internal/domain/auth"
"marktvogt.de/backend/internal/middleware"
)
// stubSessionRepo implements the minimal surface the auth middleware needs.
type stubSessionRepo struct {
session auth.Session
err error
bumped []uuid.UUID
}
func (r *stubSessionRepo) GetSessionByAccessHash(_ context.Context, _ string) (auth.Session, error) {
return r.session, r.err
}
func (r *stubSessionRepo) BumpLastUsedAt(_ context.Context, id uuid.UUID) error {
r.bumped = append(r.bumped, id)
return nil
}
func newRouter(h gin.HandlerFunc, mw ...gin.HandlerFunc) *gin.Engine {
gin.SetMode(gin.TestMode)
r := gin.New()
r.GET("/test", append(mw, h)...)
return r
}
func bearerReq(token string) *http.Request {
req := httptest.NewRequest(http.MethodGet, "/test", nil)
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
return req
}
func TestRequireAuth_ValidToken_SetsContextAndPasses(t *testing.T) {
sessionID := uuid.New()
userID := uuid.New()
stub := &stubSessionRepo{
session: auth.Session{
ID: sessionID,
UserID: userID,
UserEmail: "a@b.c",
UserRole: "user",
LastUsedAt: time.Now().Add(-2 * time.Minute),
AccessExpiresAt: time.Now().Add(28 * time.Minute),
},
}
var gotUserID, gotSessionID any
handler := func(c *gin.Context) {
gotUserID, _ = c.Get("user_id")
gotSessionID, _ = c.Get("session_id")
c.Status(http.StatusOK)
}
r := newRouter(handler, middleware.RequireAuth(stub, 30*time.Minute))
w := httptest.NewRecorder()
r.ServeHTTP(w, bearerReq("sometoken"))
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
if gotUserID != userID {
t.Errorf("user_id not set correctly in context")
}
if gotSessionID != sessionID {
t.Errorf("session_id not set correctly in context")
}
}
func TestRequireAuth_MissingToken_Returns401(t *testing.T) {
stub := &stubSessionRepo{}
r := newRouter(func(c *gin.Context) { c.Status(200) }, middleware.RequireAuth(stub, 30*time.Minute))
w := httptest.NewRecorder()
r.ServeHTTP(w, bearerReq(""))
if w.Code != http.StatusUnauthorized {
t.Errorf("expected 401, got %d", w.Code)
}
}
func TestRequireAuth_UnknownToken_Returns401(t *testing.T) {
stub := &stubSessionRepo{err: auth.ErrSessionNotFound}
r := newRouter(func(c *gin.Context) { c.Status(200) }, middleware.RequireAuth(stub, 30*time.Minute))
w := httptest.NewRecorder()
r.ServeHTTP(w, bearerReq("badtoken"))
if w.Code != http.StatusUnauthorized {
t.Errorf("expected 401, got %d", w.Code)
}
}
func TestRequireAuth_RevokedSession_Returns401(t *testing.T) {
now := time.Now()
stub := &stubSessionRepo{
session: auth.Session{
ID: uuid.New(),
UserID: uuid.New(),
AccessExpiresAt: now.Add(10 * time.Minute),
LastUsedAt: now.Add(-1 * time.Minute),
RevokedAt: &now,
},
}
r := newRouter(func(c *gin.Context) { c.Status(200) }, middleware.RequireAuth(stub, 30*time.Minute))
w := httptest.NewRecorder()
r.ServeHTTP(w, bearerReq("revokedtoken"))
if w.Code != http.StatusUnauthorized {
t.Errorf("expected 401, got %d", w.Code)
}
}
func TestRequireAuth_BumpsLastUsedAt_WhenStale(t *testing.T) {
sessionID := uuid.New()
stub := &stubSessionRepo{
session: auth.Session{
ID: sessionID,
UserID: uuid.New(),
UserEmail: "x@y.z",
UserRole: "user",
LastUsedAt: time.Now().Add(-90 * time.Second), // older than 60s threshold
AccessExpiresAt: time.Now().Add(28 * time.Minute),
},
}
r := newRouter(func(c *gin.Context) { c.Status(200) }, middleware.RequireAuth(stub, 30*time.Minute))
w := httptest.NewRecorder()
r.ServeHTTP(w, bearerReq("sometoken"))
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
if len(stub.bumped) == 0 {
t.Error("expected BumpLastUsedAt to be called for stale last_used_at")
}
}
func TestRequireAuth_DoesNotBumpLastUsedAt_WhenFresh(t *testing.T) {
stub := &stubSessionRepo{
session: auth.Session{
ID: uuid.New(),
UserID: uuid.New(),
UserEmail: "x@y.z",
UserRole: "user",
LastUsedAt: time.Now().Add(-10 * time.Second), // fresher than 60s threshold
AccessExpiresAt: time.Now().Add(28 * time.Minute),
},
}
r := newRouter(func(c *gin.Context) { c.Status(200) }, middleware.RequireAuth(stub, 30*time.Minute))
w := httptest.NewRecorder()
r.ServeHTTP(w, bearerReq("sometoken"))
if len(stub.bumped) != 0 {
t.Error("expected BumpLastUsedAt to be skipped for fresh last_used_at")
}
}
// Ensure stubSessionRepo satisfies the SessionLookup interface at compile time.
var _ middleware.SessionLookup = (*stubSessionRepo)(nil)
// Verifies the wiring used by every admin route in routes.go: RequireAuth
// followed by RequireRole("admin"). A valid session whose user_role is "user"
// must be rejected with 403 — never reach the handler.
func TestAdminChain_UserRole_Returns403(t *testing.T) {
stub := &stubSessionRepo{
session: auth.Session{
ID: uuid.New(),
UserID: uuid.New(),
UserEmail: "u@example.com",
UserRole: "user",
LastUsedAt: time.Now().Add(-10 * time.Second),
AccessExpiresAt: time.Now().Add(28 * time.Minute),
},
}
handlerCalled := false
r := newRouter(
func(c *gin.Context) {
handlerCalled = true
c.Status(http.StatusOK)
},
middleware.RequireAuth(stub, 30*time.Minute),
middleware.RequireRole("admin"),
)
w := httptest.NewRecorder()
r.ServeHTTP(w, bearerReq("user-token"))
if w.Code != http.StatusForbidden {
t.Errorf("expected 403, got %d", w.Code)
}
if handlerCalled {
t.Error("handler must not run when role check fails")
}
}
func TestAdminChain_AdminRole_Passes(t *testing.T) {
stub := &stubSessionRepo{
session: auth.Session{
ID: uuid.New(),
UserID: uuid.New(),
UserEmail: "a@example.com",
UserRole: "admin",
LastUsedAt: time.Now().Add(-10 * time.Second),
AccessExpiresAt: time.Now().Add(28 * time.Minute),
},
}
r := newRouter(
func(c *gin.Context) { c.Status(http.StatusOK) },
middleware.RequireAuth(stub, 30*time.Minute),
middleware.RequireRole("admin"),
)
w := httptest.NewRecorder()
r.ServeHTTP(w, bearerReq("admin-token"))
if w.Code != http.StatusOK {
t.Errorf("expected 200, got %d", w.Code)
}
}
func TestAdminChain_NoBearerToken_Returns401(t *testing.T) {
stub := &stubSessionRepo{}
r := newRouter(
func(c *gin.Context) { c.Status(http.StatusOK) },
middleware.RequireAuth(stub, 30*time.Minute),
middleware.RequireRole("admin"),
)
w := httptest.NewRecorder()
r.ServeHTTP(w, bearerReq(""))
if w.Code != http.StatusUnauthorized {
t.Errorf("expected 401 (auth before role check), got %d", w.Code)
}
}