merge: maestro/tsk_tFMU7-hKzfOf [tsk_tFMU7-hKzfOf] codes 激活码模块
解决与 1A 骨架的冲突:module 统一为 github.com/wangjia/pangolin/server (codes 分支原用 pangolin/server,7 个源文件 import 已改写); go.mod require 并集(redis 取 9.20.1);Makefile 以骨架为基底并入 build-codegen/test-unit/test-integration target。 go build/vet 通过,codes 单测通过。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,237 @@
|
||||
package codes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
"github.com/wangjia/pangolin/server/internal/apierr"
|
||||
)
|
||||
|
||||
// WebhookHandler handles POST /webhook/store/codes requests from the
|
||||
// card store (发卡店). It is mounted outside /v1 and requires no JWT.
|
||||
//
|
||||
// Security model:
|
||||
// - HMAC-SHA256 signature in X-Pangolin-Signature: sha256=<hex>
|
||||
// - Unix timestamp in X-Pangolin-Timestamp (±5 min window)
|
||||
// - Unique nonce in X-Pangolin-Nonce to prevent replay attacks
|
||||
// - Nonce is stored in Redis with a TTL > 2× the timestamp window
|
||||
type WebhookHandler struct {
|
||||
store *Store
|
||||
rdb *redis.Client
|
||||
secret []byte
|
||||
timestampTolerance time.Duration
|
||||
nonceTTL time.Duration
|
||||
}
|
||||
|
||||
// NewWebhookHandler creates a WebhookHandler.
|
||||
// secret is the raw HMAC key (not hex-encoded).
|
||||
func NewWebhookHandler(
|
||||
store *Store,
|
||||
rdb *redis.Client,
|
||||
secret string,
|
||||
timestampTolerance time.Duration,
|
||||
nonceTTL time.Duration,
|
||||
) *WebhookHandler {
|
||||
return &WebhookHandler{
|
||||
store: store,
|
||||
rdb: rdb,
|
||||
secret: []byte(secret),
|
||||
timestampTolerance: timestampTolerance,
|
||||
nonceTTL: nonceTTL,
|
||||
}
|
||||
}
|
||||
|
||||
// WebhookPayload is the JSON body sent by the card store.
|
||||
type WebhookPayload struct {
|
||||
// Code is the plaintext activation code generated by the card store.
|
||||
Code string `json:"code"`
|
||||
// Plan is the plan tier (free|pro|team).
|
||||
Plan string `json:"plan"`
|
||||
// DurationDays is the number of days this code grants.
|
||||
DurationDays int `json:"duration_days"`
|
||||
// Note is an optional human-readable remark.
|
||||
Note string `json:"note,omitempty"`
|
||||
}
|
||||
|
||||
// redisKeyNonce returns the Redis key for a webhook nonce.
|
||||
func redisKeyNonce(nonce string) string {
|
||||
return "webhook:nonce:" + nonce
|
||||
}
|
||||
|
||||
// ServeHTTP implements http.Handler for POST /webhook/store/codes.
|
||||
func (h *WebhookHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
// Read body (limit to 64 KB to prevent DoS).
|
||||
body, err := io.ReadAll(io.LimitReader(r.Body, 64*1024))
|
||||
if err != nil {
|
||||
apierr.WriteJSON(w, http.StatusBadRequest, apierr.ErrBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// --- Signature verification ---
|
||||
if apiErr := h.verifySignature(r, body); apiErr != nil {
|
||||
apierr.WriteJSON(w, http.StatusUnauthorized, apiErr)
|
||||
return
|
||||
}
|
||||
|
||||
// --- Timestamp window ---
|
||||
if apiErr := h.verifyTimestamp(r); apiErr != nil {
|
||||
apierr.WriteJSON(w, http.StatusUnauthorized, apiErr)
|
||||
return
|
||||
}
|
||||
|
||||
// --- Nonce deduplication ---
|
||||
nonce := r.Header.Get("X-Pangolin-Nonce")
|
||||
if nonce == "" {
|
||||
apierr.WriteJSON(w, http.StatusBadRequest, apierr.ErrBadRequest)
|
||||
return
|
||||
}
|
||||
isDupe, apiErr := h.checkAndStoreNonce(r.Context(), nonce)
|
||||
if apiErr != nil {
|
||||
apierr.WriteJSON(w, http.StatusInternalServerError, apierr.ErrInternal)
|
||||
return
|
||||
}
|
||||
if isDupe {
|
||||
// Idempotent: return 200 to acknowledge without re-inserting.
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{"status": "duplicate_ignored"})
|
||||
return
|
||||
}
|
||||
|
||||
// --- Parse payload ---
|
||||
var payload WebhookPayload
|
||||
if err := json.Unmarshal(body, &payload); err != nil {
|
||||
apierr.WriteJSON(w, http.StatusBadRequest, apierr.ErrBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if payload.Code == "" || payload.Plan == "" || payload.DurationDays <= 0 {
|
||||
apierr.WriteJSON(w, http.StatusBadRequest, apierr.ErrBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// --- Canonicalize and hash the code ---
|
||||
canonical, cerr := Canonicalize(payload.Code)
|
||||
if cerr != nil {
|
||||
apierr.WriteJSON(w, http.StatusBadRequest, apierr.ErrInvalidCode)
|
||||
return
|
||||
}
|
||||
hash := Hash(canonical)
|
||||
|
||||
// --- Idempotent code insertion ---
|
||||
ctx := r.Context()
|
||||
|
||||
planID, err := h.store.GetPlanID(ctx, PlanCode(strings.ToLower(payload.Plan)))
|
||||
if err != nil {
|
||||
apierr.WriteJSON(w, http.StatusBadRequest, apierr.ErrBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Create batch (one per webhook call; the store may aggregate externally).
|
||||
batchID, err := h.store.CreateBatch(ctx, ChannelStore, "webhook", payload.Note)
|
||||
if err != nil {
|
||||
apierr.WriteJSON(w, http.StatusInternalServerError, apierr.ErrInternal)
|
||||
return
|
||||
}
|
||||
|
||||
err = h.store.CreateCode(ctx, hash, planID, payload.DurationDays, batchID)
|
||||
if err == ErrDuplicate {
|
||||
// Same code_hash already exists – idempotent, do not error.
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{"status": "already_exists"})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
apierr.WriteJSON(w, http.StatusInternalServerError, apierr.ErrInternal)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{"status": "created"})
|
||||
}
|
||||
|
||||
// verifySignature checks the X-Pangolin-Signature header.
|
||||
// Expected format: "sha256=<lowercase-hex-hmac>"
|
||||
// HMAC is computed over the raw request body.
|
||||
func (h *WebhookHandler) verifySignature(r *http.Request, body []byte) *apierr.Error {
|
||||
sig := r.Header.Get("X-Pangolin-Signature")
|
||||
if sig == "" {
|
||||
return apierr.ErrWebhookSignature
|
||||
}
|
||||
if !strings.HasPrefix(sig, "sha256=") {
|
||||
return apierr.ErrWebhookSignature
|
||||
}
|
||||
gotHex := strings.TrimPrefix(sig, "sha256=")
|
||||
gotBytes, err := hex.DecodeString(gotHex)
|
||||
if err != nil {
|
||||
return apierr.ErrWebhookSignature
|
||||
}
|
||||
|
||||
mac := hmac.New(sha256.New, h.secret)
|
||||
mac.Write(body)
|
||||
expected := mac.Sum(nil)
|
||||
|
||||
// Constant-time comparison to prevent timing attacks.
|
||||
if !hmac.Equal(gotBytes, expected) {
|
||||
return apierr.ErrWebhookSignature
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// verifyTimestamp checks the X-Pangolin-Timestamp header.
|
||||
// The timestamp must be within ± h.timestampTolerance of the current time.
|
||||
func (h *WebhookHandler) verifyTimestamp(r *http.Request) *apierr.Error {
|
||||
tsStr := r.Header.Get("X-Pangolin-Timestamp")
|
||||
if tsStr == "" {
|
||||
return apierr.ErrWebhookTimestamp
|
||||
}
|
||||
ts, err := strconv.ParseInt(tsStr, 10, 64)
|
||||
if err != nil {
|
||||
return apierr.ErrWebhookTimestamp
|
||||
}
|
||||
t := time.Unix(ts, 0)
|
||||
now := time.Now().UTC()
|
||||
diff := now.Sub(t)
|
||||
if diff < 0 {
|
||||
diff = -diff
|
||||
}
|
||||
if diff > h.timestampTolerance {
|
||||
return apierr.ErrWebhookTimestamp
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkAndStoreNonce checks whether nonce has been seen before.
|
||||
// If not seen, it stores it in Redis with the nonce TTL and returns false.
|
||||
// If already seen, it returns true (duplicate).
|
||||
// Uses SET NX to make the check-and-store atomic.
|
||||
// Returns an error if rdb is nil (misconfiguration).
|
||||
func (h *WebhookHandler) checkAndStoreNonce(ctx context.Context, nonce string) (bool, error) {
|
||||
if h.rdb == nil {
|
||||
return false, fmt.Errorf("webhook: Redis client is not configured")
|
||||
}
|
||||
key := redisKeyNonce(nonce)
|
||||
// SET key "1" NX EX <ttl seconds>
|
||||
set, err := h.rdb.SetNX(ctx, key, "1", h.nonceTTL).Result()
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("webhook checkNonce: %w", err)
|
||||
}
|
||||
// SetNX returns true if the key was set (not a duplicate).
|
||||
return !set, nil
|
||||
}
|
||||
Reference in New Issue
Block a user