feat(api): API keys + Redis rate limiting + usage stats
Add an optional API-key layer to the public read-only API. Keys grant higher per-minute rate limits and attribute usage; anonymous callers are still allowed at a lower IP-based budget. - migration 0008_api_key: api_key table (sha256 hash only, plaintext shown once) - apikey pkg: key generation + hashing - ratelimit pkg: Redis fixed-window limiter + per-key usage counters; fails open - public API middleware: X-API-Key / Bearer auth, X-RateLimit-* headers, 429+Retry-After - admin: issue/list/revoke keys + usage view (API + UI tab) Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
@@ -15,6 +15,7 @@ import (
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
|
||||
"github.com/baicai2026-baicai/goods/api/internal/ratelimit"
|
||||
"github.com/baicai2026-baicai/goods/api/internal/store"
|
||||
)
|
||||
|
||||
@@ -24,17 +25,35 @@ const APIVersion = "v1"
|
||||
const (
|
||||
defaultPageSize = 20
|
||||
maxPageSize = 100
|
||||
|
||||
// defaultAnonLimit is the per-minute request budget for unauthenticated
|
||||
// callers (identified by client IP) when none is configured.
|
||||
defaultAnonLimit = 60
|
||||
)
|
||||
|
||||
// Handler holds dependencies shared by the HTTP routes.
|
||||
type Handler struct {
|
||||
store *store.Store
|
||||
spa fs.FS
|
||||
store *store.Store
|
||||
spa fs.FS
|
||||
limiter *ratelimit.Limiter
|
||||
anonLimit int
|
||||
}
|
||||
|
||||
// New constructs a Handler backed by the given store. spa may be nil (JSON-only).
|
||||
// Rate limiting is disabled until WithRateLimit is called.
|
||||
func New(s *store.Store, spa fs.FS) *Handler {
|
||||
return &Handler{store: s, spa: spa}
|
||||
return &Handler{store: s, spa: spa, anonLimit: defaultAnonLimit}
|
||||
}
|
||||
|
||||
// WithRateLimit attaches a Redis-backed limiter and the anonymous per-minute
|
||||
// budget, enabling rate limiting + usage tracking on the public API routes.
|
||||
// A non-positive anonPerMin keeps the default.
|
||||
func (h *Handler) WithRateLimit(l *ratelimit.Limiter, anonPerMin int) *Handler {
|
||||
h.limiter = l
|
||||
if anonPerMin > 0 {
|
||||
h.anonLimit = anonPerMin
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
// Router builds the top-level HTTP handler with middleware and routes mounted.
|
||||
@@ -47,6 +66,7 @@ func (h *Handler) Router() http.Handler {
|
||||
r.Get("/healthz", h.Healthz)
|
||||
|
||||
r.Route("/api/"+APIVersion, func(r chi.Router) {
|
||||
r.Use(h.rateLimit)
|
||||
r.Route("/products", func(r chi.Router) {
|
||||
r.Get("/barcode/{gtin}", h.ProductByBarcode)
|
||||
r.Get("/search", h.SearchProducts)
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/baicai2026-baicai/goods/api/internal/apikey"
|
||||
"github.com/baicai2026-baicai/goods/api/internal/store"
|
||||
)
|
||||
|
||||
type ctxKey int
|
||||
|
||||
const apiKeyIDKey ctxKey = 0
|
||||
|
||||
// rateLimit authenticates an optional API key and enforces a per-minute budget
|
||||
// on the public API. Anonymous callers are limited by client IP at a lower
|
||||
// budget; a valid key raises the budget and attributes usage. An API key that
|
||||
// is present but invalid or revoked is rejected with 401. Rate-limit headers
|
||||
// are set on every response; over-budget callers get 429 + Retry-After.
|
||||
func (h *Handler) rateLimit(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
id := "ip:" + clientIP(r)
|
||||
limit := h.anonLimit
|
||||
keyID := ""
|
||||
|
||||
if raw := presentedKey(r); raw != "" {
|
||||
if !apikey.IsWellFormed(raw) {
|
||||
writeError(w, r, http.StatusUnauthorized, "invalid_api_key", "API key 格式无效")
|
||||
return
|
||||
}
|
||||
k, err := h.store.APIKeyByHash(r.Context(), apikey.Hash(raw))
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
writeError(w, r, http.StatusUnauthorized, "invalid_api_key", "API key 无效或已吊销")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
writeError(w, r, http.StatusInternalServerError, "internal_error", "internal server error")
|
||||
return
|
||||
}
|
||||
keyID = k.ID
|
||||
limit = k.RateLimitPerMin
|
||||
id = "key:" + k.ID
|
||||
}
|
||||
|
||||
res := h.limiter.Allow(r.Context(), id, limit, time.Minute)
|
||||
w.Header().Set("X-RateLimit-Limit", strconv.Itoa(res.Limit))
|
||||
w.Header().Set("X-RateLimit-Remaining", strconv.Itoa(res.Remaining))
|
||||
w.Header().Set("X-RateLimit-Reset", strconv.FormatInt(res.ResetUnix, 10))
|
||||
if !res.Allowed {
|
||||
retry := res.ResetUnix - time.Now().Unix()
|
||||
if retry < 1 {
|
||||
retry = 1
|
||||
}
|
||||
w.Header().Set("Retry-After", strconv.FormatInt(retry, 10))
|
||||
writeError(w, r, http.StatusTooManyRequests, "rate_limited", "请求过于频繁,请稍后再试")
|
||||
return
|
||||
}
|
||||
|
||||
if keyID != "" {
|
||||
h.limiter.RecordUsage(r.Context(), keyID)
|
||||
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), apiKeyIDKey, keyID)))
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// presentedKey extracts an API key from the X-API-Key header or a Bearer token.
|
||||
func presentedKey(r *http.Request) string {
|
||||
if v := strings.TrimSpace(r.Header.Get("X-API-Key")); v != "" {
|
||||
return v
|
||||
}
|
||||
if v := r.Header.Get("Authorization"); strings.HasPrefix(v, "Bearer ") {
|
||||
return strings.TrimSpace(strings.TrimPrefix(v, "Bearer "))
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// clientIP returns the caller IP, preferring chi's RealIP-normalized RemoteAddr.
|
||||
func clientIP(r *http.Request) string {
|
||||
if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil {
|
||||
return host
|
||||
}
|
||||
return r.RemoteAddr
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"github.com/baicai2026-baicai/goods/api/internal/apikey"
|
||||
"github.com/baicai2026-baicai/goods/api/internal/ratelimit"
|
||||
"github.com/baicai2026-baicai/goods/api/internal/store"
|
||||
)
|
||||
|
||||
// newRateLimitedHandler builds a handler backed by the test DB and a live Redis
|
||||
// limiter, plus a freshly issued API key with the given per-minute limit. It
|
||||
// skips when either backend is unavailable.
|
||||
func newRateLimitedHandler(t *testing.T, keyLimit int) (h *Handler, plaintextKey string) {
|
||||
t.Helper()
|
||||
dsn := os.Getenv("OPENGOODS_DATABASE_URL")
|
||||
if dsn == "" {
|
||||
dsn = "postgres://opengoods:opengoods@localhost:5432/opengoods?sslmode=disable"
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
pool, err := pgxpool.New(ctx, dsn)
|
||||
if err != nil {
|
||||
t.Skipf("no database: %v", err)
|
||||
}
|
||||
if err := pool.Ping(ctx); err != nil {
|
||||
pool.Close()
|
||||
t.Skipf("database not reachable: %v", err)
|
||||
}
|
||||
var hasTable bool
|
||||
if err := pool.QueryRow(ctx, "SELECT to_regclass('public.api_key') IS NOT NULL").Scan(&hasTable); err != nil || !hasTable {
|
||||
pool.Close()
|
||||
t.Skip("migrations not applied (api_key missing)")
|
||||
}
|
||||
|
||||
redisURL := os.Getenv("OPENGOODS_REDIS_URL")
|
||||
if redisURL == "" {
|
||||
redisURL = "redis://localhost:6379/0"
|
||||
}
|
||||
limiter := ratelimit.New(redisURL)
|
||||
pingCtx, pingCancel := context.WithTimeout(context.Background(), time.Second)
|
||||
defer pingCancel()
|
||||
if err := limiter.Ping(pingCtx); err != nil {
|
||||
pool.Close()
|
||||
t.Skipf("redis not reachable: %v", err)
|
||||
}
|
||||
|
||||
key, hash, prefix, err := apikey.Generate()
|
||||
if err != nil {
|
||||
pool.Close()
|
||||
t.Fatal(err)
|
||||
}
|
||||
name := fmt.Sprintf("test-key-%d", time.Now().UnixNano())
|
||||
if _, err := pool.Exec(context.Background(),
|
||||
`INSERT INTO api_key (name, key_prefix, key_hash, rate_limit_per_min) VALUES ($1,$2,$3,$4)`,
|
||||
name, prefix, hash, keyLimit); err != nil {
|
||||
pool.Close()
|
||||
t.Fatalf("insert api_key: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = pool.Exec(context.Background(), "DELETE FROM api_key WHERE key_hash=$1", hash)
|
||||
pool.Close()
|
||||
})
|
||||
|
||||
return New(store.New(pool), nil).WithRateLimit(limiter, 60), key
|
||||
}
|
||||
|
||||
func TestRateLimitHeadersAndKeyAuth(t *testing.T) {
|
||||
h, key := newRateLimitedHandler(t, 100)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/"+APIVersion+"/categories", nil)
|
||||
req.Header.Set("X-API-Key", key)
|
||||
rec := httptest.NewRecorder()
|
||||
h.Router().ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if got := rec.Header().Get("X-RateLimit-Limit"); got != "100" {
|
||||
t.Fatalf("X-RateLimit-Limit = %q, want 100 (key limit)", got)
|
||||
}
|
||||
if rec.Header().Get("X-RateLimit-Remaining") == "" {
|
||||
t.Fatal("missing X-RateLimit-Remaining header")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvalidKeyRejected(t *testing.T) {
|
||||
h, _ := newRateLimitedHandler(t, 100)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/"+APIVersion+"/categories", nil)
|
||||
req.Header.Set("X-API-Key", "og_live_thiskeydoesnotexist123456")
|
||||
rec := httptest.NewRecorder()
|
||||
h.Router().ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("status = %d, want 401; body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRateLimitExceeded(t *testing.T) {
|
||||
h, key := newRateLimitedHandler(t, 1)
|
||||
do := func() int {
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/"+APIVersion+"/categories", nil)
|
||||
req.Header.Set("X-API-Key", key)
|
||||
rec := httptest.NewRecorder()
|
||||
h.Router().ServeHTTP(rec, req)
|
||||
return rec.Code
|
||||
}
|
||||
if code := do(); code != http.StatusOK {
|
||||
t.Fatalf("first request status = %d, want 200", code)
|
||||
}
|
||||
if code := do(); code != http.StatusTooManyRequests {
|
||||
t.Fatalf("second request status = %d, want 429", code)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user