787151245e
解决与 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>
238 lines
6.9 KiB
Go
238 lines
6.9 KiB
Go
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
|
||
}
|