afcd7b325c
Implements server/internal/codes/ with all required functionality:
## Generator (generator.go)
- Crockford Base32 16-char codes (15 data + 1 Crockford mod-37 check char)
- crypto/rand for unbiased random generation with rejection sampling
- Canonicalize(): I/L→1, O→0 folding, hyphen/space stripping
- Hash(): SHA-256 of canonical plaintext (only value stored in DB)
- Algorithm hard-coded; check char detects all single-char substitution errors
## Store (store.go)
- MySQL-backed via database/sql
- CreateBatch / CreateCode with ErrDuplicate on UNIQUE conflict
- FindCodeByHashForUpdate: SELECT … FOR UPDATE for row-level concurrency control
- MarkRedeemed, ExtendSubscription, CreateSubscription, GetActiveSubscriptions
- WriteAuditLog, CodeExistsByHash
- BeginTx at READ COMMITTED (FOR UPDATE provides row exclusivity)
## Service (service.go)
- Redeem(): 9-step flow with full idempotency and concurrency safety
- isLocked / recordFail / clearFail via Redis key redeem:fail:{user_id}
- SELECT … FOR UPDATE → single winner under N-concurrent redemptions
- Same-plan: extends existing subscription (max(expires_at,now)+days)
- Cross-plan: creates new subscription (max(now,latest)+days)
- Idempotent: same user re-submits → 200 without re-applying
- 5 failures → ACCOUNT_LOCKED for 1 hour (sliding window via Redis)
- CreateBatch(): generates N codes, stores hashes, returns plaintext once
- Automatic retry on hash collision (birthday probability ≈10⁻⁸)
## Webhook (webhook.go)
- POST /webhook/store/codes — outside /v1, no JWT required
- HMAC-SHA256 with hmac.Equal constant-time comparison
- ±5 min timestamp window
- Redis SetNX nonce deduplication (15-min TTL)
- Idempotent: duplicate nonce → 200; duplicate code_hash → 200
## HTTP Handler (handler.go)
- POST /v1/redeem endpoint wired to Service.Redeem
- Reads userID from context key (set by JWT middleware from auth module)
- Bilingual error responses {code, message_zh, message_en}
## Export (export.go)
- ExportCSV(): streams plaintext codes to io.Writer as CSV
- Plaintext NEVER stored in DB; only SHA-256 hash persists
## CLI (cmd/codegen/main.go)
- codegen -plan -days -count -channel -note -dsn [-out]
- Transition tool until admin panel (#8) is ready
- Outputs CSV to stdout or file; warns operator about plaintext sensitivity
## Infrastructure (skeleton)
- internal/config/config.go: env-var configuration
- internal/db/db.go: MySQL connection pool helper
- internal/redisutil/redis.go: Redis client constructor
- internal/apierr/apierr.go: bilingual error types
- migrations/001_init.sql: DDL for codes module tables
- go.mod with all dependencies
## Tests
- generator_test.go (unit, no deps):
- Format, uniqueness (10k codes, zero collisions), normalization,
check-char detection of all single-char errors, hash consistency
- webhook_test.go (unit, no deps):
- Signature rejection, missing signature, stale/future timestamp,
missing nonce, constant-time HMAC comparison
- service_test.go (//go:build integration, testcontainers):
- N=20 concurrent redeemers → exactly 1 winner
- Idempotent redeem returns success for same user
- Other-user redemption → CODE_REDEEMED + failure counted
- 5 failures → ACCOUNT_LOCKED on 6th attempt
- Same-plan extension: expires_at precision assertion
- Cross-plan creation: new subscription row
- Audit log written on every successful redemption
- CSV export: no plaintext in DB, hash present
- Webhook nonce replay: idempotent 200
- Webhook same-hash: idempotent 200, single DB row
Run unit tests: make test
Run integration tests: make test-integration (requires Docker)
Co-Authored-By: Claude Sonnet 4.6 <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"
|
||
"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
|
||
}
|