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:
wangjia
2026-06-13 03:03:11 +08:00
18 changed files with 2851 additions and 20 deletions
+237
View File
@@ -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
}