2820823b36
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>
91 lines
2.7 KiB
Go
91 lines
2.7 KiB
Go
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
|
|
}
|