fix auth completion and payment resume hardening

This commit is contained in:
IanShaw027
2026-04-21 08:23:26 +08:00
parent f11b7d5105
commit 09351e9459
8 changed files with 199 additions and 47 deletions

View File

@@ -190,8 +190,9 @@ func (s *PaymentService) VerifyOrderByOutTradeNo(ctx context.Context, outTradeNo
return o, nil
}
// VerifyOrderPublic verifies payment status without user authentication.
// Used by the payment result page when the user's session has expired.
// VerifyOrderPublic returns the currently persisted public order state without
// triggering any upstream reconciliation. Signed resume-token recovery is the
// only public recovery path allowed to query upstream state.
func (s *PaymentService) VerifyOrderPublic(ctx context.Context, outTradeNo string) (*dbent.PaymentOrder, error) {
o, err := s.entClient.PaymentOrder.Query().
Where(paymentorder.OutTradeNo(outTradeNo)).
@@ -199,15 +200,6 @@ func (s *PaymentService) VerifyOrderPublic(ctx context.Context, outTradeNo strin
if err != nil {
return nil, infraerrors.NotFound("NOT_FOUND", "order not found")
}
if o.Status == OrderStatusPending || o.Status == OrderStatusExpired {
result := s.checkPaid(ctx, o)
if result == checkPaidResultAlreadyPaid {
o, err = s.entClient.PaymentOrder.Get(ctx, o.ID)
if err != nil {
return nil, fmt.Errorf("reload order: %w", err)
}
}
}
return o, nil
}

View File

@@ -200,3 +200,48 @@ func TestGetPublicOrderByResumeTokenChecksUpstreamForPendingOrder(t *testing.T)
require.Equal(t, order.ID, got.ID)
require.Equal(t, 1, provider.queryCount)
}
func TestVerifyOrderPublicDoesNotCheckUpstreamForPendingOrder(t *testing.T) {
ctx := context.Background()
client := newPaymentConfigServiceTestClient(t)
user, err := client.User.Create().
SetEmail("public-verify@example.com").
SetPasswordHash("hash").
SetUsername("public-verify-user").
Save(ctx)
require.NoError(t, err)
order, err := client.PaymentOrder.Create().
SetUserID(user.ID).
SetUserEmail(user.Email).
SetUserName(user.Username).
SetAmount(88).
SetPayAmount(88).
SetFeeRate(0).
SetRechargeCode("PUBLIC-VERIFY").
SetOutTradeNo("sub2_public_verify_pending").
SetPaymentType(payment.TypeAlipay).
SetPaymentTradeNo("trade-public-verify").
SetOrderType(payment.OrderTypeBalance).
SetStatus(OrderStatusPending).
SetExpiresAt(time.Now().Add(time.Hour)).
SetClientIP("127.0.0.1").
SetSrcHost("api.example.com").
Save(ctx)
require.NoError(t, err)
registry := payment.NewRegistry()
provider := &paymentResumeLookupProvider{}
registry.Register(provider)
svc := &PaymentService{
entClient: client,
registry: registry,
providersLoaded: true,
}
got, err := svc.VerifyOrderPublic(ctx, order.OutTradeNo)
require.NoError(t, err)
require.Equal(t, order.ID, got.ID)
require.Equal(t, 0, provider.queryCount)
}

View File

@@ -36,6 +36,9 @@ const (
paymentResumeNotConfiguredCode = "PAYMENT_RESUME_NOT_CONFIGURED"
paymentResumeNotConfiguredMessage = "payment resume tokens require a configured signing key"
paymentResumeTokenTTL = 24 * time.Hour
wechatPaymentResumeTokenTTL = 15 * time.Minute
)
type ResumeTokenClaims struct {
@@ -46,6 +49,7 @@ type ResumeTokenClaims struct {
PaymentType string `json:"pt,omitempty"`
CanonicalReturnURL string `json:"ru,omitempty"`
IssuedAt int64 `json:"iat"`
ExpiresAt int64 `json:"exp,omitempty"`
}
type WeChatPaymentResumeClaims struct {
@@ -58,6 +62,7 @@ type WeChatPaymentResumeClaims struct {
RedirectTo string `json:"rd,omitempty"`
Scope string `json:"scp,omitempty"`
IssuedAt int64 `json:"iat"`
ExpiresAt int64 `json:"exp,omitempty"`
}
type PaymentResumeService struct {
@@ -263,6 +268,9 @@ func (s *PaymentResumeService) CreateToken(claims ResumeTokenClaims) (string, er
if claims.IssuedAt == 0 {
claims.IssuedAt = time.Now().Unix()
}
if claims.ExpiresAt == 0 {
claims.ExpiresAt = time.Now().Add(paymentResumeTokenTTL).Unix()
}
return s.createSignedToken(claims)
}
@@ -277,6 +285,9 @@ func (s *PaymentResumeService) ParseToken(token string) (*ResumeTokenClaims, err
if claims.OrderID <= 0 {
return nil, infraerrors.BadRequest("INVALID_RESUME_TOKEN", "resume token missing order id")
}
if err := validatePaymentResumeExpiry(claims.ExpiresAt, "INVALID_RESUME_TOKEN", "resume token has expired"); err != nil {
return nil, err
}
return &claims, nil
}
@@ -291,6 +302,9 @@ func (s *PaymentResumeService) CreateWeChatPaymentResumeToken(claims WeChatPayme
if claims.IssuedAt == 0 {
claims.IssuedAt = time.Now().Unix()
}
if claims.ExpiresAt == 0 {
claims.ExpiresAt = time.Now().Add(wechatPaymentResumeTokenTTL).Unix()
}
if normalized := NormalizeVisibleMethod(claims.PaymentType); normalized != "" {
claims.PaymentType = normalized
}
@@ -319,6 +333,9 @@ func (s *PaymentResumeService) ParseWeChatPaymentResumeToken(token string) (*WeC
if claims.OpenID == "" {
return nil, infraerrors.BadRequest("INVALID_WECHAT_PAYMENT_RESUME_TOKEN", "wechat payment resume token missing openid")
}
if err := validatePaymentResumeExpiry(claims.ExpiresAt, "INVALID_WECHAT_PAYMENT_RESUME_TOKEN", "wechat payment resume token has expired"); err != nil {
return nil, err
}
if normalized := NormalizeVisibleMethod(claims.PaymentType); normalized != "" {
claims.PaymentType = normalized
}
@@ -355,6 +372,16 @@ func (s *PaymentResumeService) parseSignedToken(token string, dest any) error {
return json.Unmarshal(payload, dest)
}
func validatePaymentResumeExpiry(expiresAt int64, code, message string) error {
if expiresAt <= 0 {
return nil
}
if time.Now().Unix() > expiresAt {
return infraerrors.BadRequest(code, message)
}
return nil
}
func (s *PaymentResumeService) sign(payload string) string {
mac := hmac.New(sha256.New, s.signingKey)
_, _ = mac.Write([]byte(payload))

View File

@@ -11,6 +11,7 @@ import (
"net/url"
"strconv"
"testing"
"time"
"github.com/Wei-Shaw/sub2api/internal/payment"
)
@@ -175,6 +176,26 @@ func TestParseTokenRejectsFallbackSignedTokenWhenSigningKeyMissing(t *testing.T)
}
}
func TestParseTokenRejectsExpiredToken(t *testing.T) {
t.Parallel()
svc := NewPaymentResumeService([]byte("0123456789abcdef0123456789abcdef"))
token, err := svc.CreateToken(ResumeTokenClaims{
OrderID: 42,
UserID: 7,
IssuedAt: time.Now().Add(-25 * time.Hour).Unix(),
ExpiresAt: time.Now().Add(-1 * time.Hour).Unix(),
})
if err != nil {
t.Fatalf("CreateToken returned error: %v", err)
}
_, err = svc.ParseToken(token)
if err == nil {
t.Fatal("ParseToken should reject expired tokens")
}
}
func TestWeChatPaymentResumeTokenRoundTrip(t *testing.T) {
t.Parallel()
@@ -233,6 +254,26 @@ func TestParseWeChatPaymentResumeTokenRejectsFallbackSignedTokenWhenSigningKeyMi
}
}
func TestParseWeChatPaymentResumeTokenRejectsExpiredToken(t *testing.T) {
t.Parallel()
svc := NewPaymentResumeService([]byte("0123456789abcdef0123456789abcdef"))
token, err := svc.CreateWeChatPaymentResumeToken(WeChatPaymentResumeClaims{
OpenID: "openid-123",
PaymentType: payment.TypeWxpay,
IssuedAt: time.Now().Add(-30 * time.Minute).Unix(),
ExpiresAt: time.Now().Add(-1 * time.Minute).Unix(),
})
if err != nil {
t.Fatalf("CreateWeChatPaymentResumeToken returned error: %v", err)
}
_, err = svc.ParseWeChatPaymentResumeToken(token)
if err == nil {
t.Fatal("ParseWeChatPaymentResumeToken should reject expired tokens")
}
}
func TestNormalizeVisibleMethodSource(t *testing.T) {
t.Parallel()