Tighten WeChat payment resume flow
This commit is contained in:
@@ -33,3 +33,7 @@ func (s *PaymentService) GetPublicOrderByResumeToken(ctx context.Context, token
|
||||
|
||||
return order, nil
|
||||
}
|
||||
|
||||
func (s *PaymentService) ParseWeChatPaymentResumeToken(token string) (*WeChatPaymentResumeClaims, error) {
|
||||
return s.paymentResume().ParseWeChatPaymentResumeToken(strings.TrimSpace(token))
|
||||
}
|
||||
|
||||
@@ -31,6 +31,8 @@ const (
|
||||
VisibleMethodSourceEasyPayAlipay = "easypay_alipay"
|
||||
VisibleMethodSourceOfficialWechat = "official_wxpay"
|
||||
VisibleMethodSourceEasyPayWechat = "easypay_wxpay"
|
||||
|
||||
wechatPaymentResumeTokenType = "wechat_payment_resume"
|
||||
)
|
||||
|
||||
type ResumeTokenClaims struct {
|
||||
@@ -43,6 +45,18 @@ type ResumeTokenClaims struct {
|
||||
IssuedAt int64 `json:"iat"`
|
||||
}
|
||||
|
||||
type WeChatPaymentResumeClaims struct {
|
||||
TokenType string `json:"tk,omitempty"`
|
||||
OpenID string `json:"openid"`
|
||||
PaymentType string `json:"pt,omitempty"`
|
||||
Amount string `json:"amt,omitempty"`
|
||||
OrderType string `json:"ot,omitempty"`
|
||||
PlanID int64 `json:"pid,omitempty"`
|
||||
RedirectTo string `json:"rd,omitempty"`
|
||||
Scope string `json:"scp,omitempty"`
|
||||
IssuedAt int64 `json:"iat"`
|
||||
}
|
||||
|
||||
type PaymentResumeService struct {
|
||||
signingKey []byte
|
||||
}
|
||||
@@ -232,6 +246,66 @@ func (s *PaymentResumeService) CreateToken(claims ResumeTokenClaims) (string, er
|
||||
if claims.IssuedAt == 0 {
|
||||
claims.IssuedAt = time.Now().Unix()
|
||||
}
|
||||
return s.createSignedToken(claims)
|
||||
}
|
||||
|
||||
func (s *PaymentResumeService) ParseToken(token string) (*ResumeTokenClaims, error) {
|
||||
var claims ResumeTokenClaims
|
||||
if err := s.parseSignedToken(token, &claims); err != nil {
|
||||
return nil, infraerrors.BadRequest("INVALID_RESUME_TOKEN", "resume token payload is invalid")
|
||||
}
|
||||
if claims.OrderID <= 0 {
|
||||
return nil, infraerrors.BadRequest("INVALID_RESUME_TOKEN", "resume token missing order id")
|
||||
}
|
||||
return &claims, nil
|
||||
}
|
||||
|
||||
func (s *PaymentResumeService) CreateWeChatPaymentResumeToken(claims WeChatPaymentResumeClaims) (string, error) {
|
||||
claims.OpenID = strings.TrimSpace(claims.OpenID)
|
||||
if claims.OpenID == "" {
|
||||
return "", fmt.Errorf("wechat payment resume token requires openid")
|
||||
}
|
||||
if claims.IssuedAt == 0 {
|
||||
claims.IssuedAt = time.Now().Unix()
|
||||
}
|
||||
if normalized := NormalizeVisibleMethod(claims.PaymentType); normalized != "" {
|
||||
claims.PaymentType = normalized
|
||||
}
|
||||
if claims.PaymentType == "" {
|
||||
claims.PaymentType = payment.TypeWxpay
|
||||
}
|
||||
if claims.OrderType == "" {
|
||||
claims.OrderType = payment.OrderTypeBalance
|
||||
}
|
||||
claims.TokenType = wechatPaymentResumeTokenType
|
||||
return s.createSignedToken(claims)
|
||||
}
|
||||
|
||||
func (s *PaymentResumeService) ParseWeChatPaymentResumeToken(token string) (*WeChatPaymentResumeClaims, error) {
|
||||
var claims WeChatPaymentResumeClaims
|
||||
if err := s.parseSignedToken(token, &claims); err != nil {
|
||||
return nil, infraerrors.BadRequest("INVALID_WECHAT_PAYMENT_RESUME_TOKEN", "wechat payment resume token payload is invalid")
|
||||
}
|
||||
if claims.TokenType != wechatPaymentResumeTokenType {
|
||||
return nil, infraerrors.BadRequest("INVALID_WECHAT_PAYMENT_RESUME_TOKEN", "wechat payment resume token type mismatch")
|
||||
}
|
||||
claims.OpenID = strings.TrimSpace(claims.OpenID)
|
||||
if claims.OpenID == "" {
|
||||
return nil, infraerrors.BadRequest("INVALID_WECHAT_PAYMENT_RESUME_TOKEN", "wechat payment resume token missing openid")
|
||||
}
|
||||
if normalized := NormalizeVisibleMethod(claims.PaymentType); normalized != "" {
|
||||
claims.PaymentType = normalized
|
||||
}
|
||||
if claims.PaymentType == "" {
|
||||
claims.PaymentType = payment.TypeWxpay
|
||||
}
|
||||
if claims.OrderType == "" {
|
||||
claims.OrderType = payment.OrderTypeBalance
|
||||
}
|
||||
return &claims, nil
|
||||
}
|
||||
|
||||
func (s *PaymentResumeService) createSignedToken(claims any) (string, error) {
|
||||
payload, err := json.Marshal(claims)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("marshal resume claims: %w", err)
|
||||
@@ -240,26 +314,19 @@ func (s *PaymentResumeService) CreateToken(claims ResumeTokenClaims) (string, er
|
||||
return encodedPayload + "." + s.sign(encodedPayload), nil
|
||||
}
|
||||
|
||||
func (s *PaymentResumeService) ParseToken(token string) (*ResumeTokenClaims, error) {
|
||||
func (s *PaymentResumeService) parseSignedToken(token string, dest any) error {
|
||||
parts := strings.Split(token, ".")
|
||||
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
|
||||
return nil, infraerrors.BadRequest("INVALID_RESUME_TOKEN", "resume token is malformed")
|
||||
return infraerrors.BadRequest("INVALID_RESUME_TOKEN", "resume token is malformed")
|
||||
}
|
||||
if !hmac.Equal([]byte(parts[1]), []byte(s.sign(parts[0]))) {
|
||||
return nil, infraerrors.BadRequest("INVALID_RESUME_TOKEN", "resume token signature mismatch")
|
||||
return infraerrors.BadRequest("INVALID_RESUME_TOKEN", "resume token signature mismatch")
|
||||
}
|
||||
payload, err := base64.RawURLEncoding.DecodeString(parts[0])
|
||||
if err != nil {
|
||||
return nil, infraerrors.BadRequest("INVALID_RESUME_TOKEN", "resume token payload is malformed")
|
||||
return infraerrors.BadRequest("INVALID_RESUME_TOKEN", "resume token payload is malformed")
|
||||
}
|
||||
var claims ResumeTokenClaims
|
||||
if err := json.Unmarshal(payload, &claims); err != nil {
|
||||
return nil, infraerrors.BadRequest("INVALID_RESUME_TOKEN", "resume token payload is invalid")
|
||||
}
|
||||
if claims.OrderID <= 0 {
|
||||
return nil, infraerrors.BadRequest("INVALID_RESUME_TOKEN", "resume token missing order id")
|
||||
}
|
||||
return &claims, nil
|
||||
return json.Unmarshal(payload, dest)
|
||||
}
|
||||
|
||||
func (s *PaymentResumeService) sign(payload string) string {
|
||||
|
||||
@@ -150,6 +150,39 @@ func TestPaymentResumeTokenRoundTrip(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestWeChatPaymentResumeTokenRoundTrip(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
svc := NewPaymentResumeService([]byte("0123456789abcdef0123456789abcdef"))
|
||||
token, err := svc.CreateWeChatPaymentResumeToken(WeChatPaymentResumeClaims{
|
||||
OpenID: "openid-123",
|
||||
PaymentType: payment.TypeWxpay,
|
||||
Amount: "12.50",
|
||||
OrderType: payment.OrderTypeSubscription,
|
||||
PlanID: 7,
|
||||
RedirectTo: "/purchase?from=wechat",
|
||||
Scope: "snsapi_base",
|
||||
IssuedAt: 1234567890,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateWeChatPaymentResumeToken returned error: %v", err)
|
||||
}
|
||||
|
||||
claims, err := svc.ParseWeChatPaymentResumeToken(token)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseWeChatPaymentResumeToken returned error: %v", err)
|
||||
}
|
||||
if claims.OpenID != "openid-123" || claims.PaymentType != payment.TypeWxpay {
|
||||
t.Fatalf("claims mismatch: %+v", claims)
|
||||
}
|
||||
if claims.Amount != "12.50" || claims.OrderType != payment.OrderTypeSubscription || claims.PlanID != 7 {
|
||||
t.Fatalf("claims payment context mismatch: %+v", claims)
|
||||
}
|
||||
if claims.RedirectTo != "/purchase?from=wechat" || claims.Scope != "snsapi_base" {
|
||||
t.Fatalf("claims redirect/scope mismatch: %+v", claims)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeVisibleMethodSource(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user