Files
pangolin/server/internal/codes/webhook.go
T
wangjia 787151245e 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>
2026-06-13 03:03:11 +08:00

238 lines
6.9 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}