Block guest-role tokens from online payment routes (RequireNonGuest middleware)

Guests (account_role='guest') have no login flow and never receive a JWT
normally, so this is defense-in-depth: any token whose role claim is 'guest'
(forged/minted guest tokens or future changes) is refused 403 before the money
handlers run. The check reads role from context ONLY — real guests are seeded
with account_type='email', so account_type is never the discriminator. A
missing role passes through (RequireAuth guarantees presence; same trust model
as isVerifiedRole).

Wired onto all user-facing money routes: booking payment, apply-redemption,
payment-lock POST/DELETE, tip, gift-card redeem and gift-card buy. Admin and
till routes (RequireAdmin) are untouched — admin can never be guest. The
payment-methods routes gain RequireVerified (verified_email, admin) alongside,
so only verified accounts can manage saved cards.

Tests: 6 middleware tests (reject guest, allow verified/unverified/admin/
affiliate/missing-role) + 4 integration tests (guest 403 on booking payment
with zero side effects, tip, gift-card buy; verified user still pays 200).
This commit is contained in:
2026-08-22 00:34:49 +01:00
parent 858ae87e9c
commit 2b4c50b4b0
4 changed files with 260 additions and 10 deletions
+19
View File
@@ -95,6 +95,25 @@ func RequireVerified(next http.Handler) http.Handler {
return RequireRole("verified_email", "admin")(next)
}
// RequireNonGuest middleware - blocks guest-role tokens from money routes.
// Guests are created with account_role='guest' (account_type is NOT checked —
// real guests are seeded with account_type='email'), and they have no login
// flow, so a legitimate guest never holds a JWT. This is a defense-in-depth
// guard: any token whose role claim is 'guest' (forged/minted guest tokens or
// future changes) is refused. A missing role is treated as not-guest and passes
// through — RequireAuth guarantees the role is present when this runs, matching
// the trust model of the in-handler isVerifiedRole checks.
func RequireNonGuest(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
role, ok := r.Context().Value(UserRoleKey).(string)
if ok && role == "guest" {
RespondJSON(w, http.StatusForbidden, map[string]string{"error": "forbidden"})
return
}
next.ServeHTTP(w, r)
})
}
// RequireAdmin middleware - only allows admin and validates the user ID is present
// in context, so handlers don't need to re-check. The user ID is always set by
// RequireAuth before this runs, but this adds a defensive safety net.
+75
View File
@@ -307,6 +307,81 @@ func TestRequireVerified_Unverified(t *testing.T) {
}
}
// =============================================================================
// RequireNonGuest (blocks account_role='guest' tokens from money routes)
// =============================================================================
func TestRequireNonGuest_AllowsVerifiedEmail(t *testing.T) {
req := httptest.NewRequest("GET", "/", nil)
ctx := context.WithValue(req.Context(), UserRoleKey, "verified_email")
req = req.WithContext(ctx)
w := httptest.NewRecorder()
RequireNonGuest(testHandler()).ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected 200 for verified_email, got %d", w.Code)
}
}
func TestRequireNonGuest_AllowsUnverifiedEmail(t *testing.T) {
req := httptest.NewRequest("GET", "/", nil)
ctx := context.WithValue(req.Context(), UserRoleKey, "unverified_email")
req = req.WithContext(ctx)
w := httptest.NewRecorder()
RequireNonGuest(testHandler()).ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected 200 for unverified_email, got %d", w.Code)
}
}
func TestRequireNonGuest_AllowsAdmin(t *testing.T) {
req := httptest.NewRequest("GET", "/", nil)
ctx := context.WithValue(req.Context(), UserRoleKey, "admin")
req = req.WithContext(ctx)
w := httptest.NewRecorder()
RequireNonGuest(testHandler()).ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected 200 for admin, got %d", w.Code)
}
}
func TestRequireNonGuest_AllowsAffiliate(t *testing.T) {
req := httptest.NewRequest("GET", "/", nil)
ctx := context.WithValue(req.Context(), UserRoleKey, "affiliate")
req = req.WithContext(ctx)
w := httptest.NewRecorder()
RequireNonGuest(testHandler()).ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected 200 for affiliate, got %d", w.Code)
}
}
func TestRequireNonGuest_RejectsGuest(t *testing.T) {
req := httptest.NewRequest("GET", "/", nil)
ctx := context.WithValue(req.Context(), UserRoleKey, "guest")
req = req.WithContext(ctx)
w := httptest.NewRecorder()
RequireNonGuest(testHandler()).ServeHTTP(w, req)
if w.Code != http.StatusForbidden {
t.Errorf("expected 403 for guest, got %d", w.Code)
}
}
func TestRequireNonGuest_NoRoleInContext(t *testing.T) {
req := httptest.NewRequest("GET", "/", nil)
// No UserRoleKey in context — treated as not-guest, passes through
w := httptest.NewRecorder()
RequireNonGuest(testHandler()).ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected 200 when no role in context, got %d", w.Code)
}
}
// =============================================================================
// RequireAdmin (RequireRole("admin") + UserIDKey guard)
// =============================================================================