废除浏览器回跳自助结算,通用 webhook 强制密钥鉴权,Waffo 支持验签与未知事件忽略;重构订单状态机/激活占座/SQLite WAL,并补备份、readyz、限流与前端导出/登录页安全修复。 Co-authored-by: Cursor <cursoragent@cursor.com>
128 lines
3.4 KiB
Go
128 lines
3.4 KiB
Go
package payment
|
||
|
||
import (
|
||
"crypto"
|
||
"crypto/rsa"
|
||
"crypto/sha256"
|
||
"crypto/x509"
|
||
"encoding/base64"
|
||
"encoding/pem"
|
||
"errors"
|
||
"fmt"
|
||
"os"
|
||
"strings"
|
||
|
||
"backend/internal/model"
|
||
|
||
"gorm.io/gorm"
|
||
)
|
||
|
||
// LoadWaffoWebhookVerifyKey 读取 webhook 验签材料:环境变量优先,其次网关 WebhookSecret // Logan.Li
|
||
func LoadWaffoWebhookVerifyKey(db *gorm.DB) string {
|
||
if v := strings.TrimSpace(os.Getenv("WAFFO_WEBHOOK_PUBLIC_KEY")); v != "" {
|
||
return v
|
||
}
|
||
if v := strings.TrimSpace(os.Getenv("WAFFO_WEBHOOK_SECRET")); v != "" {
|
||
return v
|
||
}
|
||
if db == nil {
|
||
return ""
|
||
}
|
||
var gw model.PaymentGatewayConfig
|
||
if err := db.Where("gateway = ?", "waffo").First(&gw).Error; err != nil {
|
||
return ""
|
||
}
|
||
return strings.TrimSpace(gw.WebhookSecret)
|
||
}
|
||
|
||
// VerifyWaffoWebhookSignature 校验 Waffo webhook:RSA PKCS1v15(SHA256(body)) 或 HMAC 共享密钥 // Logan.Li
|
||
func VerifyWaffoWebhookSignature(verifyKey string, body []byte, sigHeader string) error {
|
||
verifyKey = strings.TrimSpace(verifyKey)
|
||
sigHeader = strings.TrimSpace(sigHeader)
|
||
if verifyKey == "" {
|
||
return errors.New("未配置 webhook 验签公钥/密钥")
|
||
}
|
||
if sigHeader == "" {
|
||
return errors.New("缺少 X-Waffo-Signature")
|
||
}
|
||
|
||
// 共享密钥模式:签名头即为密钥(或 Bearer 同值) // Logan.Li
|
||
if !looksLikePEMOrDER(verifyKey) {
|
||
secret := strings.TrimPrefix(sigHeader, "Bearer ")
|
||
if subtleConstantTimeEq(secret, verifyKey) || subtleConstantTimeEq(sigHeader, verifyKey) {
|
||
return nil
|
||
}
|
||
return errors.New("webhook 共享密钥不匹配")
|
||
}
|
||
|
||
pub, err := parseRSAPublicKey(verifyKey)
|
||
if err != nil {
|
||
return fmt.Errorf("webhook 验签公钥无效: %w", err)
|
||
}
|
||
sig, err := base64.StdEncoding.DecodeString(sigHeader)
|
||
if err != nil {
|
||
// 兼容 URL-safe base64 // Logan.Li
|
||
sig, err = base64.RawURLEncoding.DecodeString(sigHeader)
|
||
if err != nil {
|
||
return errors.New("签名格式错误")
|
||
}
|
||
}
|
||
sum := sha256.Sum256(body)
|
||
if err := rsa.VerifyPKCS1v15(pub, crypto.SHA256, sum[:], sig); err != nil {
|
||
return errors.New("webhook 签名校验失败")
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func looksLikePEMOrDER(s string) bool {
|
||
return strings.Contains(s, "BEGIN") || len(s) > 80
|
||
}
|
||
|
||
func subtleConstantTimeEq(a, b string) bool {
|
||
if len(a) != len(b) {
|
||
return false
|
||
}
|
||
var v byte
|
||
for i := 0; i < len(a); i++ {
|
||
v |= a[i] ^ b[i]
|
||
}
|
||
return v == 0
|
||
}
|
||
|
||
func parseRSAPublicKey(raw string) (*rsa.PublicKey, error) {
|
||
raw = strings.TrimSpace(raw)
|
||
raw = strings.ReplaceAll(raw, `\n`, "\n")
|
||
if block, _ := pem.Decode([]byte(raw)); block != nil {
|
||
return parseRSAPublicKeyBytes(block.Bytes)
|
||
}
|
||
compact := strings.Map(func(r rune) rune {
|
||
if r == '\n' || r == '\r' || r == ' ' || r == '\t' {
|
||
return -1
|
||
}
|
||
return r
|
||
}, raw)
|
||
if decoded, err := base64.StdEncoding.DecodeString(compact); err == nil {
|
||
if pub, err2 := parseRSAPublicKeyBytes(decoded); err2 == nil {
|
||
return pub, nil
|
||
}
|
||
}
|
||
wrapped := "-----BEGIN PUBLIC KEY-----\n" + chunkBase64(compact, 64) + "\n-----END PUBLIC KEY-----\n"
|
||
if block, _ := pem.Decode([]byte(wrapped)); block != nil {
|
||
return parseRSAPublicKeyBytes(block.Bytes)
|
||
}
|
||
return nil, errors.New("无法解析 RSA 公钥")
|
||
}
|
||
|
||
func parseRSAPublicKeyBytes(der []byte) (*rsa.PublicKey, error) {
|
||
if pub, err := x509.ParsePKIXPublicKey(der); err == nil {
|
||
if rsaPub, ok := pub.(*rsa.PublicKey); ok {
|
||
return rsaPub, nil
|
||
}
|
||
return nil, errors.New("公钥不是 RSA")
|
||
}
|
||
if pub, err := x509.ParsePKCS1PublicKey(der); err == nil {
|
||
return pub, nil
|
||
}
|
||
return nil, errors.New("不支持的公钥格式")
|
||
}
|