diff --git a/internal/app/httpserver/router.go b/internal/app/httpserver/router.go index 555331fb..4a55ccff 100644 --- a/internal/app/httpserver/router.go +++ b/internal/app/httpserver/router.go @@ -89,6 +89,7 @@ func SetupRouter(cfg *config.Config, c *container.Container) *gin.Engine { adminPaymentChannelHandler := paymentHandlers.AdminChannel paymentWebhookHandler := paymentHandlers.Webhook paymentCallbackHandler := paymentHandlers.Callback + paymentEpayRedirectHandler := paymentHandlers.EpayRedirect publicConfigHandler := publicconfigwiring.NewHandler(c) userCartHandler := carttransport.NewUserHandler(c.CartService) channelHandler := channelwiring.NewHandler(c) @@ -264,7 +265,7 @@ func SetupRouter(cfg *config.Config, c *container.Container) *gin.Engine { registerStorefrontRoutes(apiV1, cfg, c, publicContentHandler, publicCatalogHandler, publicCategoryHandler, userResellerHandler, userResellerProductSettingHandler, userResellerFinanceHandler, userResellerOrderHandler, userApiCredentialHandler, userAuditLogHandler, userGiftCardHandler, publicMemberLevelHandler, userProfileHandler, userEmailHandler, userPasswordHandler, userVerifyHandler, userTelegramOIDCHandler, userTelegramHandler, userGoogleHandler, userLoginHandler, user2FAHandler, publicConfigHandler, userCartHandler, userOrderHandler, guestOrderHandler, orderPreviewHandler, orderCreateHandler, paymentLatestHandler, paymentWriteHandler, userWalletHandler, redisClient, loginRule, guestReadRule, guestWriteRule, giftCardRedeemRule) registerUpstreamRoutes(apiV1, c, upstreamHandler, redisClient, upstreamAPIRule, callbackRule) registerChannelRoutes(apiV1, c, channelHandler, channelMemberLevelHandler, channelGiftCardHandler, channelAffiliateHandler, channelTelegramBotHandler, channelWalletHandler, redisClient, channelAPIRule) - registerPaymentCallbackRoutes(apiV1, paymentCallbackHandler, paymentWebhookHandler, redisClient, callbackRule) + registerPaymentCallbackRoutes(apiV1, paymentCallbackHandler, paymentWebhookHandler, paymentEpayRedirectHandler, redisClient, callbackRule) registerAdminRoutes(r, apiV1, cfg, c, adminLoginHandler, admin2FAHandler, adminUser2FAHandler, adminUserHandler, adminAuthzHandler, adminFulfillmentHandler, adminOrderHandler, adminOrderRefundHandler, adminContentHandler, adminDashboardHandler, adminMemberLevelHandler, adminApiCredentialHandler, adminAuditLogHandler, adminCardSecretHandler, adminCatalogCategoryHandler, adminCatalogProductHandler, adminCatalogProductMappingHandler, adminCouponHandler, adminGiftCardHandler, adminPromotionHandler, adminNotificationHandler, adminProcurementHandler, adminResellerManagementHandler, adminResellerProfileDetailHandler, adminResellerSiteConfigHandler, adminResellerProductSettingHandler, adminResellerOperationsHandler, adminResellerFinanceHandler, adminSettingsHandler, adminWalletHandler, adminPaymentHandler, adminPaymentChannelHandler, redisClient, adminLoginRule) // 健康检查 diff --git a/internal/app/httpserver/routes_storefront.go b/internal/app/httpserver/routes_storefront.go index 471849ac..ef4a00ed 100644 --- a/internal/app/httpserver/routes_storefront.go +++ b/internal/app/httpserver/routes_storefront.go @@ -167,8 +167,9 @@ func registerStorefrontRoutes( } } -func registerPaymentCallbackRoutes(apiV1 *gin.RouterGroup, callbackHandler *paymentcallbacktransport.Handler, webhookHandler *paymenttransport.WebhookHandler, redisClient *redis.Client, callbackRule middleware.RateLimitRule) { +func registerPaymentCallbackRoutes(apiV1 *gin.RouterGroup, callbackHandler *paymentcallbacktransport.Handler, webhookHandler *paymenttransport.WebhookHandler, epayRedirectHandler *paymenttransport.EpayRedirectHandler, redisClient *redis.Client, callbackRule middleware.RateLimitRule) { callbacks := apiV1.Group("", middleware.RateLimitMiddleware(redisClient, callbackRule, middleware.KeyByIP)) paymentcallbacktransport.RegisterRoutes(callbacks, callbackHandler) paymenttransport.RegisterWebhookRoutes(callbacks, webhookHandler) + paymenttransport.RegisterEpayRedirectRoute(callbacks, epayRedirectHandler) } diff --git a/internal/bootstrap/payment/wiring.go b/internal/bootstrap/payment/wiring.go index 68bdc243..a7178162 100644 --- a/internal/bootstrap/payment/wiring.go +++ b/internal/bootstrap/payment/wiring.go @@ -15,6 +15,7 @@ type Handlers struct { AdminChannel *paymenttransport.AdminChannelHandler Webhook *paymenttransport.WebhookHandler Callback *paymentcallbacktransport.Handler + EpayRedirect *paymenttransport.EpayRedirectHandler } // New assembles payment transports from application services and HTTP handlers. @@ -53,5 +54,6 @@ func New(c *container.Container) Handlers { c.PaymentChannelStore, alerter, ), + EpayRedirect: paymenttransport.NewEpayRedirectHandler(c.PaymentStore), } } diff --git a/internal/modules/payment/infrastructure/gateway/adapters/epay/adapter.go b/internal/modules/payment/infrastructure/gateway/adapters/epay/adapter.go index a1f8046b..3a93f666 100644 --- a/internal/modules/payment/infrastructure/gateway/adapters/epay/adapter.go +++ b/internal/modules/payment/infrastructure/gateway/adapters/epay/adapter.go @@ -2,8 +2,13 @@ package epayadapter import ( "context" + "crypto/rand" + "encoding/hex" "errors" "fmt" + "net/http" + "net/url" + "strconv" "strings" gatewaycommon "github.com/dujiao-next/internal/modules/payment/infrastructure/gateway/common" @@ -148,9 +153,19 @@ func (a *epayAdapter) CreatePayment(ctx context.Context, raw jsonmap.JSON, input payload["original_currency"] = originalCurrency } + redirectURL := result.PayURL + if mode == constants.PaymentInteractionRedirect && epaySubmitPOST(payload) { + formURL, token, formErr := epayRedirectFormURL(input.PaymentID, returnURL, notifyURL) + if formErr != nil { + return nil, formErr + } + payload["token"] = token + redirectURL = formURL + } + return &paymentcontract.GatewayCreateResult{ ProviderRef: result.TradeNo, - RedirectURL: result.PayURL, + RedirectURL: redirectURL, QRCodeURL: result.QRCode, Payload: payload, AmountSent: payAmount, @@ -158,6 +173,54 @@ func (a *epayAdapter) CreatePayment(ctx context.Context, raw jsonmap.JSON, input }, nil } +func epaySubmitPOST(payload jsonmap.JSON) bool { + method, _ := payload["submit_method"].(string) + return strings.EqualFold(strings.TrimSpace(method), http.MethodPost) +} + +// epayRedirectFormURL 把 v2 跳转收成站内表单地址。域名优先取 return_url,为空再退回 notify_url。 +func epayRedirectFormURL(paymentID uint, returnURL, notifyURL string) (string, string, error) { + if paymentID == 0 { + return "", "", fmt.Errorf("%w: epay redirect payment id is required", paymentcontract.ErrGatewayConfigInvalid) + } + originSource := strings.TrimSpace(returnURL) + if originSource == "" { + originSource = strings.TrimSpace(notifyURL) + } + origin, err := httpOrigin(originSource) + if err != nil { + return "", "", fmt.Errorf("%w: epay redirect url origin", paymentcontract.ErrGatewayConfigInvalid) + } + token, err := newEpayRedirectToken() + if err != nil { + return "", "", fmt.Errorf("%w: %v", paymentcontract.ErrGatewayRequestFailed, err) + } + formURL := origin + "/api/v1/payments/epay-redirect?payment_id=" + strconv.FormatUint(uint64(paymentID), 10) + "&token=" + token + return formURL, token, nil +} + +func httpOrigin(raw string) (string, error) { + parsed, err := url.Parse(strings.TrimSpace(raw)) + if err != nil { + return "", err + } + if parsed.Scheme != "http" && parsed.Scheme != "https" { + return "", fmt.Errorf("url scheme %q", parsed.Scheme) + } + if parsed.Host == "" { + return "", fmt.Errorf("url host is empty") + } + return parsed.Scheme + "://" + parsed.Host, nil +} + +func newEpayRedirectToken() (string, error) { + buf := make([]byte, 32) + if _, err := rand.Read(buf); err != nil { + return "", err + } + return hex.EncodeToString(buf), nil +} + // VerifyCallback 实现 paymentcontract.GatewayCallbackVerifier。epay 用 form POST,body 参数忽略。 // epay 同步 callback form 字段: // - out_trade_no = 商户单号(OrderNo) diff --git a/internal/modules/payment/infrastructure/gateway/adapters/epay/adapter_test.go b/internal/modules/payment/infrastructure/gateway/adapters/epay/adapter_test.go index b52822ac..0ff14281 100644 --- a/internal/modules/payment/infrastructure/gateway/adapters/epay/adapter_test.go +++ b/internal/modules/payment/infrastructure/gateway/adapters/epay/adapter_test.go @@ -3,11 +3,16 @@ package epayadapter import ( "context" "crypto/md5" + "crypto/rand" + "crypto/rsa" + "crypto/x509" "encoding/hex" + "encoding/pem" "errors" "fmt" "net/http" "net/http/httptest" + "net/url" "sort" "strings" "testing" @@ -117,6 +122,131 @@ func TestEpayAdapter_CreatePayment_ExchangeRate_AuditFields(t *testing.T) { } } +func TestEpayAdapter_CreatePayment_V2RedirectUsesFormURL(t *testing.T) { + privateKey, publicKey := generateAdapterRSAKeyPair(t) + a := NewEpayAdapter() + notifyURL := "https://api.example.com/api/v1/payments/callback?channel_id=3" + raw := jsonmap.JSON{ + "gateway_url": "https://gateway.example.com", + "epay_version": "v2", + "merchant_id": "1002", + "private_key": privateKey, + "platform_public_key": publicKey, + "notify_url": notifyURL, + "return_url": "https://shop.example.com/pay?x=1&y=2", + } + result, err := a.CreatePayment(context.Background(), raw, paymentcontract.GatewayCreateInput{ + PaymentID: 42, + OrderNo: "DJP20002", + Subject: "测试订单2", + Currency: "CNY", + Amount: money.FromDecimal(decimal.RequireFromString("66.00")), + ChannelType: constants.PaymentChannelTypeWxpay, + Extra: jsonmap.JSON{"interaction_mode": constants.PaymentInteractionRedirect}, + }) + if err != nil { + t.Fatalf("CreatePayment() failed: %v", err) + } + + parsed, err := url.Parse(result.RedirectURL) + if err != nil { + t.Fatalf("parse redirect url: %v", err) + } + if got := parsed.Scheme + "://" + parsed.Host + parsed.Path; got != "https://shop.example.com/api/v1/payments/epay-redirect" { + t.Fatalf("redirect url = %s", result.RedirectURL) + } + if got := parsed.Query().Get("payment_id"); got != "42" { + t.Fatalf("payment_id = %s", got) + } + token := parsed.Query().Get("token") + if token == "" { + t.Fatalf("redirect url missing token: %s", result.RedirectURL) + } + if got, _ := result.Payload["token"].(string); got != token { + t.Fatalf("payload token = %q, want %q", got, token) + } + params, _ := result.Payload["params"].(map[string]interface{}) + sign, _ := params["sign"].(string) + if sign == "" { + t.Fatalf("payload sign is empty") + } + if strings.Contains(result.RedirectURL, sign) || strings.Contains(result.RedirectURL, "sign=") { + t.Fatalf("redirect url contains RSA sign: %s", result.RedirectURL) + } +} + +func TestEpayAdapter_CreatePayment_V2RedirectFailsClosed(t *testing.T) { + privateKey, publicKey := generateAdapterRSAKeyPair(t) + a := NewEpayAdapter() + base := jsonmap.JSON{ + "gateway_url": "https://gateway.example.com", + "epay_version": "v2", + "merchant_id": "1002", + "private_key": privateKey, + "platform_public_key": publicKey, + "notify_url": "https://api.example.com/api/v1/payments/callback", + "return_url": "https://shop.example.com/pay", + } + input := paymentcontract.GatewayCreateInput{ + PaymentID: 42, + OrderNo: "DJP20002", + Subject: "测试订单2", + Currency: "CNY", + Amount: money.FromDecimal(decimal.RequireFromString("66.00")), + ChannelType: constants.PaymentChannelTypeAlipay, + Extra: jsonmap.JSON{"interaction_mode": constants.PaymentInteractionRedirect}, + } + + input.PaymentID = 0 + if _, err := a.CreatePayment(context.Background(), base, input); !errors.Is(err, paymentcontract.ErrGatewayConfigInvalid) { + t.Fatalf("zero payment id err = %v, want config invalid", err) + } + + input.PaymentID = 42 + ftpReturn := jsonmap.JSON{} + for key, value := range base { + ftpReturn[key] = value + } + ftpReturn["return_url"] = "ftp://files.example.com/return" + if _, err := a.CreatePayment(context.Background(), ftpReturn, input); !errors.Is(err, paymentcontract.ErrGatewayConfigInvalid) { + t.Fatalf("non-http return origin err = %v, want config invalid", err) + } +} + +func TestEpayRedirectFormURLPrefersReturnURL(t *testing.T) { + formURL, _, err := epayRedirectFormURL(7, "https://shop.example.com/pay", "https://api.example.com/callback") + if err != nil { + t.Fatalf("return_url origin: %v", err) + } + if !strings.HasPrefix(formURL, "https://shop.example.com/api/v1/payments/epay-redirect?") { + t.Fatalf("formURL = %s, want return_url host", formURL) + } + + formURL, _, err = epayRedirectFormURL(7, "", "https://api.example.com/callback") + if err != nil { + t.Fatalf("notify_url fallback: %v", err) + } + if !strings.HasPrefix(formURL, "https://api.example.com/api/v1/payments/epay-redirect?") { + t.Fatalf("formURL = %s, want notify_url host", formURL) + } +} + +func generateAdapterRSAKeyPair(t *testing.T) (string, string) { + t.Helper() + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatalf("generate rsa key failed: %v", err) + } + privateDER := x509.MarshalPKCS1PrivateKey(key) + privatePEM := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: privateDER}) + publicDER, err := x509.MarshalPKIXPublicKey(&key.PublicKey) + if err != nil { + t.Fatalf("marshal public key failed: %v", err) + } + publicPEM := pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: publicDER}) + return string(privatePEM), string(publicPEM) +} + func TestEpayAdapter_VerifyCallbackRejectsMerchantMismatch(t *testing.T) { a := NewEpayAdapter() raw := jsonmap.JSON{ diff --git a/internal/modules/payment/infrastructure/gateway/epay/epay.go b/internal/modules/payment/infrastructure/gateway/epay/epay.go index d4b2e5c4..464902c8 100644 --- a/internal/modules/payment/infrastructure/gateway/epay/epay.go +++ b/internal/modules/payment/infrastructure/gateway/epay/epay.go @@ -425,7 +425,10 @@ func buildRedirectV2(cfg *Config, input CreateInput, payType string) (*CreateRes params["sign"] = sign params["sign_type"] = cfg.SignType endpoint := buildEndpoint(cfg.GatewayURL, epaySubmitPathV2) - return buildRedirectResult(endpoint, payType, params), nil + // v2 的 RSA sign 含 + / =,放进网关 query 会被伪静态重写破坏。只把已签名字段交给 POST 表单。 + result := newRedirectResult(endpoint, payType, params) + result.Raw["submit_method"] = http.MethodPost + return result, nil } func resolvePayType(channelType string) string { @@ -471,12 +474,17 @@ func buildRedirectResult(endpoint, payType string, params map[string]string) *Cr if encoded != "" { redirectURL += "?" + encoded } + result := newRedirectResult(endpoint, payType, params) + result.PayURL = redirectURL + return result +} + +func newRedirectResult(endpoint, payType string, params map[string]string) *CreateResult { rawParams := make(map[string]interface{}, len(params)) for key, value := range params { rawParams[key] = value } return &CreateResult{ - PayURL: redirectURL, PayType: payType, Raw: map[string]interface{}{ "mode": constants.PaymentInteractionRedirect, diff --git a/internal/modules/payment/infrastructure/gateway/epay/epay_redirect_test.go b/internal/modules/payment/infrastructure/gateway/epay/epay_redirect_test.go index c80d9ff8..b1137d1f 100644 --- a/internal/modules/payment/infrastructure/gateway/epay/epay_redirect_test.go +++ b/internal/modules/payment/infrastructure/gateway/epay/epay_redirect_test.go @@ -5,6 +5,7 @@ import ( "crypto/rsa" "crypto/x509" "encoding/pem" + "net/http" "net/url" "strings" "testing" @@ -99,25 +100,54 @@ func TestBuildRedirectURLV2(t *testing.T) { t.Fatalf("BuildRedirectURL v2 failed: %v", err) } - assertRedirectURLPath(t, result.PayURL, "https://gateway.example.com/api/pay/submit") - query := parseRedirectQuery(t, result.PayURL) - if got := query.Get("pid"); got != "1002" { + if result.PayURL != "" { + t.Fatalf("v2 redirect pay url = %q, want empty so the RSA sign is not on a query string", result.PayURL) + } + if result.Raw == nil { + t.Fatalf("v2 redirect raw payload is nil") + } + if got := rawRedirectString(t, result.Raw, "endpoint"); got != "https://gateway.example.com/api/pay/submit" { + t.Fatalf("endpoint = %s", got) + } + if got := rawRedirectString(t, result.Raw, "submit_method"); got != http.MethodPost { + t.Fatalf("submit_method = %s, want POST", got) + } + params := rawRedirectParams(t, result.Raw) + if sign := params["sign"]; sign != "" && strings.Contains(result.PayURL, sign) { + t.Fatalf("RSA sign must not be placed on a query string") + } + if got := params["pid"]; got != "1002" { t.Fatalf("pid = %s, want 1002", got) } - if got := query.Get("type"); got != constants.PaymentChannelTypeWxpay { + if got := params["type"]; got != constants.PaymentChannelTypeWxpay { t.Fatalf("type = %s, want %s", got, constants.PaymentChannelTypeWxpay) } - if got := query.Get("timestamp"); got == "" { + if got := params["out_trade_no"]; got != "DJP20002" { + t.Fatalf("out_trade_no = %s", got) + } + if got := params["notify_url"]; got != cfg.NotifyURL { + t.Fatalf("notify_url = %s", got) + } + if got := params["return_url"]; got != cfg.ReturnURL { + t.Fatalf("return_url = %s", got) + } + if got := params["name"]; got != "测试订单2" { + t.Fatalf("name = %s", got) + } + if got := params["money"]; got != "66.00" { + t.Fatalf("money = %s", got) + } + if got := params["timestamp"]; got == "" { t.Fatalf("timestamp should not be empty") } - if got := query.Get("sign_type"); got != epaySignTypeRSA { + if got := params["sign_type"]; got != epaySignTypeRSA { t.Fatalf("sign_type = %s, want %s", got, epaySignTypeRSA) } - if got := query.Get("clientip"); got != "" { - t.Fatalf("clientip should be empty for redirect mode, got %s", got) + if _, ok := params["clientip"]; ok { + t.Fatalf("clientip should be omitted for redirect mode") } - if got := query.Get("method"); got != "" { - t.Fatalf("method should be empty for redirect mode, got %s", got) + if _, ok := params["method"]; ok { + t.Fatalf("method should be omitted for redirect mode") } signContent := buildSignContent(map[string]string{ "pid": "1002", @@ -127,13 +157,39 @@ func TestBuildRedirectURLV2(t *testing.T) { "return_url": cfg.ReturnURL, "name": "测试订单2", "money": "66.00", - "timestamp": query.Get("timestamp"), + "timestamp": params["timestamp"], }) - if err := verifyRSA(signContent, query.Get("sign"), publicKeyPEM); err != nil { + if err := verifyRSA(signContent, params["sign"], publicKeyPEM); err != nil { t.Fatalf("verify redirect sign failed: %v", err) } } +func rawRedirectString(t *testing.T, raw map[string]interface{}, key string) string { + t.Helper() + value, ok := raw[key].(string) + if !ok { + t.Fatalf("raw[%s] = %#v, want string", key, raw[key]) + } + return value +} + +func rawRedirectParams(t *testing.T, raw map[string]interface{}) map[string]string { + t.Helper() + params, ok := raw["params"].(map[string]interface{}) + if !ok { + t.Fatalf("params = %#v, want map", raw["params"]) + } + out := make(map[string]string, len(params)) + for key, value := range params { + text, ok := value.(string) + if !ok { + t.Fatalf("param %s = %#v, want string", key, value) + } + out[key] = text + } + return out +} + func parseRedirectQuery(t *testing.T, rawURL string) url.Values { t.Helper() parsed, err := url.Parse(rawURL) diff --git a/internal/modules/payment/transport/http/routes.go b/internal/modules/payment/transport/http/routes.go index 47180d96..c0c051bf 100644 --- a/internal/modules/payment/transport/http/routes.go +++ b/internal/modules/payment/transport/http/routes.go @@ -68,3 +68,11 @@ func RegisterWebhookRoutes(api gin.IRoutes, handler *WebhookHandler) { api.POST("/payments/webhook/paypal", handler.PaypalWebhook) api.POST("/payments/webhook/stripe", handler.StripeWebhook) } + +// RegisterEpayRedirectRoute 注册易支付 v2 跳转表单。挂在回调限流组上,不要求用户登录。 +func RegisterEpayRedirectRoute(api gin.IRoutes, handler *EpayRedirectHandler) { + if api == nil || handler == nil { + panic("epay redirect route: required dependency is nil") + } + api.GET("/payments/epay-redirect", handler.EpayRedirect) +} diff --git a/internal/modules/payment/transport/http/webhook_handler.go b/internal/modules/payment/transport/http/webhook_handler.go index 15d06a1b..49058f1c 100644 --- a/internal/modules/payment/transport/http/webhook_handler.go +++ b/internal/modules/payment/transport/http/webhook_handler.go @@ -1,10 +1,18 @@ package paymenthttp import ( + "bytes" "context" + "crypto/sha256" + "crypto/subtle" + "html/template" "io" "net/http" + "net/url" + "sort" + "strconv" "strings" + "time" paymentdomain "github.com/dujiao-next/internal/modules/payment/domain" @@ -284,3 +292,177 @@ var paymentCallbackErrorRules = concatMappedErrors( }, paymentProviderGatewayErrorRules, ) + +// EpayRedirectPaymentLookup 只读取渲染易支付跳转表单所需的支付记录。 +type EpayRedirectPaymentLookup interface { + GetByID(id uint) (*paymentdomain.Payment, error) +} + +// EpayRedirectHandler 把已保存的 v2 跳转参数渲染成自动提交的 POST 表单。 +type EpayRedirectHandler struct { + payments EpayRedirectPaymentLookup +} + +func NewEpayRedirectHandler(payments EpayRedirectPaymentLookup) *EpayRedirectHandler { + if payments == nil { + panic("epay redirect handler: payments is nil") + } + return &EpayRedirectHandler{payments: payments} +} + +type epayRedirectView struct { + Action string + Fields []epayRedirectField +} + +type epayRedirectField struct { + Name string + Value string +} + +var epayRedirectTemplate = template.Must(template.New("epay-redirect").Parse(` + + + +
+{{- range .Fields}} + +{{- end}} + +
+ + +`)) + +// EpayRedirect 渲染自动提交的 POST 表单。不匹配的请求返回 404,且不包含表单。 +func (h *EpayRedirectHandler) EpayRedirect(c *gin.Context) { + c.Header("Cache-Control", "no-store") + paymentID, token, ok := epayRedirectQuery(c) + if !ok { + c.AbortWithStatus(http.StatusNotFound) + return + } + payment, err := h.payments.GetByID(paymentID) + if err != nil || !epayRedirectPayable(payment, time.Now()) { + c.AbortWithStatus(http.StatusNotFound) + return + } + action, fields, accepted := epayRedirectForm(payment.ProviderPayload, token) + if !accepted { + c.AbortWithStatus(http.StatusNotFound) + return + } + var body bytes.Buffer + if err := epayRedirectTemplate.Execute(&body, epayRedirectView{Action: action, Fields: fields}); err != nil { + c.AbortWithStatus(http.StatusNotFound) + return + } + c.Data(http.StatusOK, "text/html; charset=utf-8", body.Bytes()) +} + +func epayRedirectQuery(c *gin.Context) (uint, string, bool) { + rawID := strings.TrimSpace(c.Query("payment_id")) + token := strings.TrimSpace(c.Query("token")) + parsed, err := strconv.ParseUint(rawID, 10, 64) + if err != nil || parsed == 0 || token == "" || uint64(uint(parsed)) != parsed { + return 0, "", false + } + return uint(parsed), token, true +} + +func epayRedirectPayable(payment *paymentdomain.Payment, now time.Time) bool { + if payment == nil { + return false + } + if !strings.EqualFold(strings.TrimSpace(payment.ProviderType), constants.PaymentProviderEpay) { + return false + } + if !strings.EqualFold(strings.TrimSpace(payment.InteractionMode), constants.PaymentInteractionRedirect) { + return false + } + if payment.Status != constants.PaymentStatusPending { + return false + } + if payment.ExpiredAt != nil && !payment.ExpiredAt.After(now) { + return false + } + if payment.SupersededAt != nil || payment.SupersededByPaymentID != nil { + return false + } + return true +} + +func epayRedirectForm(payload jsonmap.JSON, providedToken string) (string, []epayRedirectField, bool) { + if payload == nil { + return "", nil, false + } + method, _ := payload["submit_method"].(string) + if !strings.EqualFold(strings.TrimSpace(method), http.MethodPost) { + return "", nil, false + } + storedToken, _ := payload["token"].(string) + if !epayRedirectTokenMatch(storedToken, providedToken) { + return "", nil, false + } + endpoint, _ := payload["endpoint"].(string) + endpoint = strings.TrimSpace(endpoint) + if !httpEndpoint(endpoint) { + return "", nil, false + } + params, ok := epayRedirectParams(payload["params"]) + if !ok { + return "", nil, false + } + keys := make([]string, 0, len(params)) + for key, value := range params { + if strings.TrimSpace(key) == "" || strings.TrimSpace(value) == "" { + continue + } + keys = append(keys, key) + } + if len(keys) == 0 { + return "", nil, false + } + sort.Strings(keys) + fields := make([]epayRedirectField, 0, len(keys)) + for _, key := range keys { + fields = append(fields, epayRedirectField{Name: key, Value: params[key]}) + } + return endpoint, fields, true +} + +func epayRedirectParams(raw interface{}) (map[string]string, bool) { + params, ok := raw.(map[string]interface{}) + if !ok { + return nil, false + } + out := make(map[string]string, len(params)) + for key, value := range params { + text, ok := value.(string) + if !ok { + return nil, false + } + out[key] = text + } + return out, true +} + +func httpEndpoint(raw string) bool { + parsed, err := url.Parse(raw) + if err != nil { + return false + } + if parsed.Scheme != "http" && parsed.Scheme != "https" { + return false + } + return parsed.Host != "" +} + +func epayRedirectTokenMatch(stored, provided string) bool { + if stored == "" || provided == "" { + return false + } + sumStored := sha256.Sum256([]byte(stored)) + sumProvided := sha256.Sum256([]byte(provided)) + return subtle.ConstantTimeCompare(sumStored[:], sumProvided[:]) == 1 +} diff --git a/internal/modules/payment/transport/http/webhook_handler_test.go b/internal/modules/payment/transport/http/webhook_handler_test.go index 601d4492..b5513d1f 100644 --- a/internal/modules/payment/transport/http/webhook_handler_test.go +++ b/internal/modules/payment/transport/http/webhook_handler_test.go @@ -3,12 +3,19 @@ package paymenthttp import ( "encoding/json" "errors" + "html" "net/http" "net/http/httptest" + "regexp" "strings" "testing" + "time" + paymentdomain "github.com/dujiao-next/internal/modules/payment/domain" + + "github.com/dujiao-next/internal/constants" "github.com/dujiao-next/internal/platform/http/response" + "github.com/dujiao-next/internal/shared/jsonmap" "github.com/gin-gonic/gin" ) @@ -93,3 +100,189 @@ func TestReadWebhookBodyRejectsOversizedPayload(t *testing.T) { t.Fatalf("oversized webhook body must be rejected") } } + +func TestEpayRedirectHandlerRendersPOSTForm(t *testing.T) { + gin.SetMode(gin.TestMode) + const ( + token = "0123456789abcdef0123456789abcdef" + endpoint = "https://gateway.example.com/api/pay/submit" + sign = "ab+cd/ef=" + returnURL = "https://shop.example.com/pay?foo=1&bar=2" + paymentID = uint(7) + ) + handler := NewEpayRedirectHandler(fakeEpayRedirectPayments{ + byID: map[uint]*paymentdomain.Payment{ + paymentID: epayRedirectPayment(paymentID, token, endpoint, sign, returnURL), + }, + }) + router := gin.New() + api := router.Group("/api/v1") + RegisterEpayRedirectRoute(api, handler) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/payments/epay-redirect?payment_id=7&token="+token, nil) + recorder := httptest.NewRecorder() + router.ServeHTTP(recorder, req) + + if recorder.Code != http.StatusOK { + t.Fatalf("status = %d, want 200, body=%s", recorder.Code, recorder.Body.String()) + } + if got := recorder.Header().Get("Cache-Control"); got != "no-store" { + t.Fatalf("Cache-Control = %q, want no-store", got) + } + method, action, fields := parseEpayRedirectForm(t, recorder.Body.String()) + if method != "post" { + t.Fatalf("form method = %s, want post", method) + } + if action != endpoint { + t.Fatalf("form action = %s, want %s", action, endpoint) + } + if fields["sign"] != sign { + t.Fatalf("sign = %q, want raw %q", fields["sign"], sign) + } + if fields["return_url"] != returnURL { + t.Fatalf("return_url = %q, want raw %q", fields["return_url"], returnURL) + } + if strings.Contains(fields["sign"], "%") || strings.Contains(fields["return_url"], "%") { + t.Fatalf("form values must not be percent-encoded: %#v", fields) + } + if !epayRedirectHasVisibleSubmit(recorder.Body.String()) { + t.Fatalf("visible submit control missing: %s", recorder.Body.String()) + } +} + +func TestEpayRedirectHandlerRejects(t *testing.T) { + gin.SetMode(gin.TestMode) + const ( + token = "0123456789abcdef0123456789abcdef" + endpoint = "https://gateway.example.com/api/pay/submit" + sign = "ab+cd/ef=" + returnURL = "https://shop.example.com/pay?foo=1&bar=2" + ) + base := epayRedirectPayment(7, token, endpoint, sign, returnURL) + expired := epayRedirectPayment(8, token, endpoint, sign, returnURL) + past := time.Now().Add(-time.Minute) + expired.ExpiredAt = &past + superseded := epayRedirectPayment(9, token, endpoint, sign, returnURL) + now := time.Now() + superseded.SupersededAt = &now + success := epayRedirectPayment(10, token, endpoint, sign, returnURL) + success.Status = constants.PaymentStatusSuccess + v1 := epayRedirectPayment(11, token, "https://gateway.example.com/submit.php", sign, returnURL) + delete(v1.ProviderPayload, "submit_method") + badAction := epayRedirectPayment(12, token, "javascript:alert(1)", sign, returnURL) + otherProvider := epayRedirectPayment(13, token, endpoint, sign, returnURL) + otherProvider.ProviderType = constants.PaymentProviderOfficial + + lookup := fakeEpayRedirectPayments{byID: map[uint]*paymentdomain.Payment{ + 7: base, + 8: expired, + 9: superseded, + 10: success, + 11: v1, + 12: badAction, + 13: otherProvider, + }} + router := gin.New() + RegisterEpayRedirectRoute(router.Group("/api/v1"), NewEpayRedirectHandler(lookup)) + + cases := []struct { + name string + url string + }{ + {name: "wrong token", url: "/api/v1/payments/epay-redirect?payment_id=7&token=wrong-token"}, + {name: "unknown payment", url: "/api/v1/payments/epay-redirect?payment_id=99&token=" + token}, + {name: "expired", url: "/api/v1/payments/epay-redirect?payment_id=8&token=" + token}, + {name: "superseded", url: "/api/v1/payments/epay-redirect?payment_id=9&token=" + token}, + {name: "not pending", url: "/api/v1/payments/epay-redirect?payment_id=10&token=" + token}, + {name: "v1 redirect", url: "/api/v1/payments/epay-redirect?payment_id=11&token=" + token}, + {name: "non-http action", url: "/api/v1/payments/epay-redirect?payment_id=12&token=" + token}, + {name: "wrong provider", url: "/api/v1/payments/epay-redirect?payment_id=13&token=" + token}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + recorder := httptest.NewRecorder() + router.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, tc.url, nil)) + if recorder.Code != http.StatusNotFound { + t.Fatalf("status = %d, want 404, body=%s", recorder.Code, recorder.Body.String()) + } + if strings.Contains(strings.ToLower(recorder.Body.String()), "]*)>`).FindStringSubmatch(body) + if formTag == nil { + t.Fatalf("form missing: %s", body) + } + formAttrs := htmlAttributeMap(formTag[1]) + fields := map[string]string{} + for _, match := range regexp.MustCompile(`(?is)]*)>`).FindAllStringSubmatch(body, -1) { + attrs := htmlAttributeMap(match[1]) + if !strings.EqualFold(attrs["type"], "hidden") { + continue + } + fields[html.UnescapeString(attrs["name"])] = html.UnescapeString(attrs["value"]) + } + return strings.ToLower(formAttrs["method"]), html.UnescapeString(formAttrs["action"]), fields +} + +func epayRedirectHasVisibleSubmit(body string) bool { + for _, match := range regexp.MustCompile(`(?is)<(button|input)\b([^>]*)>`).FindAllStringSubmatch(body, -1) { + attrs := htmlAttributeMap(match[2]) + if !strings.EqualFold(attrs["type"], "submit") { + continue + } + if strings.Contains(strings.ToLower(match[0]), "hidden") && strings.EqualFold(match[1], "input") && strings.EqualFold(attrs["type"], "hidden") { + continue + } + return true + } + return false +} + +func htmlAttributeMap(tag string) map[string]string { + out := map[string]string{} + for _, match := range regexp.MustCompile(`([^\s=]+)\s*=\s*(?:"([^"]*)"|'([^']*)')`).FindAllStringSubmatch(tag, -1) { + value := match[2] + if match[3] != "" { + value = match[3] + } + out[strings.ToLower(match[1])] = value + } + return out +}