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).
251 lines
6.8 KiB
Go
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)
|
|
}
|
|
}
|