Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .claude/skills/backend/references/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ All in `cmd/api/test_utils_test.go`. Use these instead of duplicating setup.

| Helper | What it does |
|--------|-------------|
| `newTestApplication(t)` | Build `*application` with mock store, no-op zap logger, mock GCS, mock mailer, real fixed-window rate limiter (20/5s), basic auth `testuser:testpass`, public API key `test-api-key`. |
| `newTestApplication(t)` | Build `*application` with mock store, no-op zap logger, mock GCS, mock mailer, real fixed-window rate limiters (20/5s per user, 200/5s per IP) with the real SuperTokens session resolver (no cookie → IP fallback, no core contact), basic auth `testuser:testpass`, public API key `test-api-key`. |
| `headerSessionUserID` | Drop-in for `app.sessionUserID` that reads the session user from the `X-Test-Session-User` header (`testSessionUserHeader`), so rate limiter tests can vary the user per request without a running core. |
| `executeRequest(req, mux)` | Run req through handler/mux, return `*httptest.ResponseRecorder`. |
| `checkResponseCode(t, expected, actual)` | Assert status code with descriptive failure. |
| `addBasicAuth(req)` | Set `Authorization: Basic <testuser:testpass>` header. |
Expand Down
8 changes: 7 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -151,9 +151,15 @@ VAPID_SUBJECT=noreply@example.com

RATE_LIMITER_ENABLED=true

# Requests per IP per five-second window.
# Requests per signed-in user per five-second window. Applies to /v1/* only.
RATELIMITER_REQUESTS_COUNT=20

# Requests per client IP per five-second window for /v1/* calls that do not
# carry a verified session (login page lookups, the first request after an
# access token expires). Larger than the per-user budget because every hacker
# at the venue typically shares one IP.
RATELIMITER_IP_REQUESTS_COUNT=200


# ── Apple Wallet passes (optional) ───────────────────────────────────────────

Expand Down
5 changes: 3 additions & 2 deletions claude.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,8 @@ Note: `air` runs `task gen-docs` as a pre-command on every rebuild, so `swag` CL
- **Entry point:** `cmd/api/main.go` — loads config, `cmd/api/api.go` — Chi router setup in `mount()`
- **Database:** PostgreSQL 16.3, raw SQL (no ORM), repository pattern in `internal/store/`
- **Auth:** SuperTokens (Passwordless magic link + Google OAuth), initialized in `internal/auth/`
- **Middleware chain:** RequestID → RealIP → Logger → Recoverer → CORS → SuperTokens → RateLimiter → AuthRequired → RequireRole
- **Middleware chain:** RequestID → RealIP → Logger → Recoverer → CORS → SuperTokens → RateLimiter (`/v1` only) → AuthRequired → RequireRole
- **Rate limiting:** keyed by SuperTokens user ID when the request carries a verified session (`RATELIMITER_REQUESTS_COUNT`), falling back to client IP otherwise (`RATELIMITER_IP_REQUESTS_COUNT`, larger because a whole venue shares one NAT). Static assets and `/auth/*` are never limited.
- **Roles (hierarchical):** `hacker` (1) < `admin` (2) < `super_admin` (3)
- **JSON envelope:** Success: `{"data": ...}`, Error: `{"error": "..."}`
- **Pagination:** Cursor-based with base64-encoded JSON cursors
Expand Down Expand Up @@ -100,7 +101,7 @@ Tests live in `cmd/api/` (`_test.go` files, same package as handlers):
- `internal/db/` — PostgreSQL connection setup (pgx)
- `internal/mailer/` — SendGrid email with embedded Go templates
- `internal/auth/` — SuperTokens init, user creation from session
- `internal/ratelimiter/` — fixed-window rate limiter
- `internal/ratelimiter/` — fixed-window rate limiter (one instance per user-ID bucket, one per IP bucket)
- `internal/logger/` — Zap logger (dev/prod modes based on `ENV`)

### Frontend (React 19 + TypeScript + Vite)
Expand Down
23 changes: 16 additions & 7 deletions cmd/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,15 @@ type application struct {
mailer mailer.Client
gcsClient gcs.Client
appleWalletPasses appleWalletPassGenerator
rateLimiter ratelimiter.Limiter
dispatcherCancel context.CancelFunc
// rateLimiter buckets requests by verified session user ID; ipRateLimiter
// is the fallback for requests without one. Split so the shared-IP budget
// (a venue full of hackers behind one NAT) can be tuned independently.
rateLimiter ratelimiter.Limiter
ipRateLimiter ratelimiter.Limiter
// sessionUserID resolves the SuperTokens user ID for a request without
// requiring a session. Injected so tests can stub it.
sessionUserID sessionUserIDResolver
dispatcherCancel context.CancelFunc
}

type config struct {
Expand Down Expand Up @@ -136,12 +143,14 @@ func (app *application) mount() http.Handler {
// Applied at root level so it intercepts /auth/* requests.
r.Use(supertokens.Middleware)

// Ratelimiter
if app.config.rateLimiter.Enabled {
r.Use(app.RateLimiterMiddleware)
}

r.Route("/v1", func(r chi.Router) {
// Ratelimiter. Scoped to the API so the SPA shell and static assets
// (served from /*) are never throttled; /auth/* is handled by the
// SuperTokens middleware above and never reaches this router.
if app.config.rateLimiter.Enabled {
r.Use(app.RateLimiterMiddleware)
}

// Public API (key auth)
r.Route("/public", func(r chi.Router) {
r.Use(app.APIKeyMiddleware)
Expand Down
4 changes: 2 additions & 2 deletions cmd/api/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,8 +58,8 @@ func (app *application) unauthorizedBasicErrorResponse(w http.ResponseWriter, r
writeJSONError(w, http.StatusUnauthorized, "unauthorized")
}

func (app *application) rateLimiterExceededResponse(w http.ResponseWriter, r *http.Request, retryAfter string) {
app.logger.Warnw("rate limit exceeded", "method", r.Method, "path", r.URL.Path)
func (app *application) rateLimiterExceededResponse(w http.ResponseWriter, r *http.Request, key, retryAfter string) {
app.logger.Warnw("rate limit exceeded", "method", r.Method, "path", r.URL.Path, "key", key)

w.Header().Set("Retry-After", retryAfter)

Expand Down
19 changes: 14 additions & 5 deletions cmd/api/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -89,10 +89,13 @@ func main() {
publicAPIKey: env.GetString("PUBLIC_API_KEY", ""),
},
rateLimiter: ratelimiter.Config{
// Limit 20 requests every 5 seconds per IP
RequestPerTimeFrame: env.GetInt("RATELIMITER_REQUESTS_COUNT", 20),
TimeFrame: time.Second * 5,
Enabled: env.GetBool("RATE_LIMITER_ENABLED", true),
// Limit 20 requests every 5 seconds per signed-in user. Requests
// without a verified session fall back to a per-IP bucket with a
// larger budget, since a whole venue can sit behind one NAT.
RequestPerTimeFrame: env.GetInt("RATELIMITER_REQUESTS_COUNT", 20),
IPRequestPerTimeFrame: env.GetInt("RATELIMITER_IP_REQUESTS_COUNT", 200),
TimeFrame: time.Second * 5,
Enabled: env.GetBool("RATE_LIMITER_ENABLED", true),
},
frontendURL: frontendURL,
publicCORSOrigin: env.GetString("PUBLIC_CORS_ORIGIN", ""),
Expand Down Expand Up @@ -199,11 +202,15 @@ func main() {
logger.Infow("gcs client initialized", "bucket", cfg.gcs.bucketName)
}

// Init rate limiter
// Init rate limiters
rateLimiter := ratelimiter.NewFixedWindowLimiter(
cfg.rateLimiter.RequestPerTimeFrame,
cfg.rateLimiter.TimeFrame,
)
ipRateLimiter := ratelimiter.NewFixedWindowLimiter(
cfg.rateLimiter.IPRequestPerTimeFrame,
cfg.rateLimiter.TimeFrame,
)

// Apple Wallet signing is optional. If explicitly enabled, invalid or
// incomplete signing material is a deployment error.
Expand All @@ -224,6 +231,8 @@ func main() {
gcsClient: gcsClient,
appleWalletPasses: appleWalletPasses,
rateLimiter: rateLimiter,
ipRateLimiter: ipRateLimiter,
sessionUserID: supertokensSessionUserID,
}

// Metrics collected
Expand Down
54 changes: 51 additions & 3 deletions cmd/api/middlewares.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,18 +6,42 @@ import (
"encoding/base64"
"errors"
"fmt"
"net"
"net/http"
"strings"

"github.com/hackutd/harp/internal/auth"
"github.com/hackutd/harp/internal/ratelimiter"
"github.com/hackutd/harp/internal/store"
"github.com/supertokens/supertokens-golang/recipe/session"
"github.com/supertokens/supertokens-golang/recipe/session/sessmodels"
)

type contextKey string

const userContextKey contextKey = "user"

// sessionUserIDResolver reports the SuperTokens user ID behind a request, or
// false when the request carries no verifiable session.
type sessionUserIDResolver func(w http.ResponseWriter, r *http.Request) (string, bool)

// supertokensSessionUserID reads the session without requiring one. Only a
// signature-verified access token yields a user ID, so a forged token cannot
// mint its own rate-limit bucket. Missing, expired, or invalid tokens report
// false and the caller falls back to the client IP. Verification is local
// (cached JWKS); no call to the SuperTokens core is made.
func supertokensSessionUserID(w http.ResponseWriter, r *http.Request) (string, bool) {
optional := false
sess, err := session.GetSession(r, w, &sessmodels.VerifySessionOptions{
SessionRequired: &optional,
AntiCsrfCheck: &optional,
})
if err != nil || sess == nil {
return "", false
}
return sess.GetUserID(), true
}

// Validates HTTP Basic authentication credentials
func (app *application) BasicAuthMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
Expand Down Expand Up @@ -49,17 +73,41 @@ func (app *application) BasicAuthMiddleware(next http.Handler) http.Handler {
})
}

// Rate limits per IP
// Rate limits per signed-in user, falling back to the client IP for requests
// without a verified session. Per-user buckets keep one venue NAT full of
// hackers from exhausting a single shared budget.
func (app *application) RateLimiterMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if allow, retryAfter := app.rateLimiter.Allow(r.RemoteAddr); !allow {
app.rateLimiterExceededResponse(w, r, retryAfter.String())
limiter, key := app.rateLimiterFor(w, r)
if allow, retryAfter := limiter.Allow(key); !allow {
app.rateLimiterExceededResponse(w, r, key, retryAfter.String())
return
}
next.ServeHTTP(w, r)
})
}

// Picks the limiter and bucket key for a request: the per-user limiter keyed
// by SuperTokens user ID when a session verifies, else the per-IP limiter.
func (app *application) rateLimiterFor(w http.ResponseWriter, r *http.Request) (ratelimiter.Limiter, string) {
if app.sessionUserID != nil {
if userID, ok := app.sessionUserID(w, r); ok {
return app.rateLimiter, "user:" + userID
}
}
return app.ipRateLimiter, "ip:" + clientIP(r)
}

// middleware.RealIP rewrites RemoteAddr to the bare forwarded IP behind a
// proxy, but without one RemoteAddr keeps its port, which would make every
// connection its own bucket.
func clientIP(r *http.Request) string {
if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil {
return host
}
return r.RemoteAddr
}

// Verifies the SuperTokens session and loads the user into context
func (app *application) AuthRequiredMiddleware(next http.Handler) http.Handler {
return session.VerifySession(nil, func(w http.ResponseWriter, r *http.Request) {
Expand Down
Loading
Loading