From 05749370f1d09e88b2cb3254545ce8dfb966f243 Mon Sep 17 00:00:00 2001 From: Jerry <85411418@qq.com> Date: Mon, 3 Jul 2023 19:04:40 +0800 Subject: [PATCH] Feat/apple client (#328) * apple update --- apple/cert.go | 30 ++++++++ apple/client.go | 101 ++++++++++++++++++++++++++ apple/client_test.go | 36 ++++++++++ apple/constant.go | 27 +++++++ apple/consts.go | 12 ---- apple/consumption.go | 23 ++++++ apple/error.go | 31 ++++++++ apple/notification_v2.go | 108 +++++----------------------- apple/notification_v2_model.go | 17 +++++ apple/notification_v2_test.go | 36 +++++++++- apple/order.go | 31 ++++++++ apple/order_model.go | 7 ++ apple/order_test.go | 36 ++++++++++ apple/refund.go | 30 ++++++++ apple/refund_model.go | 8 +++ apple/refund_test.go | 25 +++++++ apple/subscription.go | 29 +++----- apple/subscription_model.go | 5 +- apple/subscription_test.go | 17 ++--- apple/token.go | 67 ++++------------- apple/transaction.go | 46 +++++++----- apple/transaction_model.go | 22 +++++- apple/transaction_test.go | 17 ++--- apple/unsign_jwt.go | 106 +++++++++++++++++++++++++++ apple/unsign_jwt_test.go | 34 +++++++++ apple/verify.go | 9 ++- apple/{model.go => verify_model.go} | 0 apple/verify_test.go | 30 ++++++-- doc/apple.md | 45 ++++++++++-- error.go | 1 + paypal/client.go | 2 +- 31 files changed, 755 insertions(+), 233 deletions(-) create mode 100644 apple/cert.go create mode 100644 apple/client.go create mode 100644 apple/client_test.go create mode 100644 apple/constant.go delete mode 100644 apple/consts.go create mode 100644 apple/consumption.go create mode 100644 apple/error.go create mode 100644 apple/order.go create mode 100644 apple/order_model.go create mode 100644 apple/order_test.go create mode 100644 apple/refund.go create mode 100644 apple/refund_model.go create mode 100644 apple/refund_test.go create mode 100644 apple/unsign_jwt.go create mode 100644 apple/unsign_jwt_test.go rename apple/{model.go => verify_model.go} (100%) diff --git a/apple/cert.go b/apple/cert.go new file mode 100644 index 00000000..812cfe46 --- /dev/null +++ b/apple/cert.go @@ -0,0 +1,30 @@ +package apple + +import ( + "crypto/ecdsa" + "crypto/x509" + "encoding/pem" + "errors" +) + +// ParseECPrivateKeyFromPEM parses a PEM encoded Elliptic Curve Private Key Structure +func ParseECPrivateKeyFromPEM(key []byte) (*ecdsa.PrivateKey, error) { + var err error + // Parse PEM block + var block *pem.Block + if block, _ = pem.Decode(key); block == nil { + return nil, errors.New("ErrKeyMustBePEMEncoded") + } + // Parse the key + var parsedKey interface{} + if parsedKey, err = x509.ParseECPrivateKey(block.Bytes); err != nil { + if parsedKey, err = x509.ParsePKCS8PrivateKey(block.Bytes); err != nil { + return nil, err + } + } + pkey, ok := parsedKey.(*ecdsa.PrivateKey) + if !ok { + return nil, errors.New("ErrNotECPrivateKey") + } + return pkey, nil +} diff --git a/apple/client.go b/apple/client.go new file mode 100644 index 00000000..96bc4299 --- /dev/null +++ b/apple/client.go @@ -0,0 +1,101 @@ +package apple + +import ( + "context" + "crypto/ecdsa" + "net/http" + + "github.com/go-pay/gopay" + "github.com/go-pay/gopay/pkg/util" + "github.com/go-pay/gopay/pkg/xhttp" +) + +// Client AppleClient +type Client struct { + iss string // Your issuer ID from the Keys page in App Store Connect (Ex: "57246542-96fe-1a63-e053-0824d011072a") + bid string // Your app’s bundle ID (Ex: “com.example.testbundleid2021”) + kid string // Your private key ID from App Store Connect (Ex: 2X9R4HXF34) + isProd bool // 是否是正式环境 + privateKey *ecdsa.PrivateKey +} + +// NewClient 初始化Apple客户端 +// iss:issuer ID +// bid:bundle ID +// kid:private key ID +// privateKey:私钥文件读取后的字符串内容 +// isProd:是否是正式环境 +func NewClient(iss, bid, kid, privateKey string, isProd bool) (client *Client, err error) { + if iss == util.NULL || bid == util.NULL || kid == util.NULL || privateKey == util.NULL { + return nil, gopay.MissAppleInitParamErr + } + ecPrivateKey, err := ParseECPrivateKeyFromPEM([]byte(privateKey)) + if err != nil { + return nil, err + } + //ecPrivateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + //if err != nil { + // panic(err) + //} + client = &Client{ + iss: iss, + bid: bid, + kid: kid, + privateKey: ecPrivateKey, + } + return client, nil +} + +func (c *Client) doRequestGet(ctx context.Context, path string) (res *http.Response, bs []byte, err error) { + uri := hostUrl + path + if !c.isProd { + uri = sandBoxHostUrl + path + } + token, err := c.generatingToken() + if err != nil { + return nil, nil, err + } + cli := xhttp.NewClient() + cli.Header.Set("Authorization", "Bearer "+token) + res, bs, err = cli.Type(xhttp.TypeJSON).Get(uri).EndBytes(ctx) + if err != nil { + return nil, nil, err + } + return res, bs, nil +} + +func (c *Client) doRequestPost(ctx context.Context, path string, bm gopay.BodyMap) (res *http.Response, bs []byte, err error) { + uri := hostUrl + path + if !c.isProd { + uri = sandBoxHostUrl + path + } + token, err := c.generatingToken() + if err != nil { + return nil, nil, err + } + cli := xhttp.NewClient() + cli.Header.Set("Authorization", "Bearer "+token) + res, bs, err = cli.Type(xhttp.TypeJSON).Post(uri).SendBodyMap(bm).EndBytes(ctx) + if err != nil { + return nil, nil, err + } + return res, bs, nil +} + +func (c *Client) doRequestPut(ctx context.Context, path string, bm gopay.BodyMap) (res *http.Response, bs []byte, err error) { + uri := hostUrl + path + if !c.isProd { + uri = sandBoxHostUrl + path + } + token, err := c.generatingToken() + if err != nil { + return nil, nil, err + } + cli := xhttp.NewClient() + cli.Header.Set("Authorization", "Bearer "+token) + res, bs, err = cli.Type(xhttp.TypeJSON).Put(uri).SendBodyMap(bm).EndBytes(ctx) + if err != nil { + return nil, nil, err + } + return res, bs, nil +} diff --git a/apple/client_test.go b/apple/client_test.go new file mode 100644 index 00000000..22084599 --- /dev/null +++ b/apple/client_test.go @@ -0,0 +1,36 @@ +package apple + +import ( + "context" + "os" + "testing" + + "github.com/go-pay/gopay/pkg/xlog" +) + +var ( + ctx = context.Background() + client *Client + err error + + iss = "57246542-96fe-1a63-e053-0824d011072a" + bid = "com.example.testbundleid2021" + kid = "2X9R4HXF34" +) + +func TestMain(m *testing.M) { + xlog.Level = xlog.DebugLevel + // 初始化客户端 + // iss:issuer ID + // bid:bundle ID + // kid:private key ID + // privateKey:私钥文件读取后的字符串内容 + // isProd:是否是正式环境 + client, err = NewClient(iss, bid, kid, "privateKey", false) + if err != nil { + xlog.Error(err) + return + } + + os.Exit(m.Run()) +} diff --git a/apple/constant.go b/apple/constant.go new file mode 100644 index 00000000..1fe84f1f --- /dev/null +++ b/apple/constant.go @@ -0,0 +1,27 @@ +package apple + +const ( + hostUrl = "https://api.storekit.itunes.apple.com" + sandBoxHostUrl = "https://api.storekit-sandbox.itunes.apple.com" + + // Get Transaction History + getTransactionHistory = "/inApps/v1/history/%s" // transactionId + + // Get Transaction Info + getTransactionInfo = "/inApps/v1/transactions/%s" // transactionId + + // Get All Subscription Statuses + getAllSubscriptionStatuses = "/inApps/v1/subscriptions/%s" // transactionId + + // Send Consumption Information + sendConsumptionInformation = "/inApps/v1/transactions/consumption/%s" // transactionId + + // Look Up Order ID + lookUpOrderID = "/inApps/v1/lookup/%s" // orderId + + // Get Subscription Status + getRefundHistory = "/inApps/v2/refund/lookup/%s" // transactionId + + // Get Notification History + getNotificationHistory = "/inApps/v1/notifications/history" +) diff --git a/apple/consts.go b/apple/consts.go deleted file mode 100644 index 8148e391..00000000 --- a/apple/consts.go +++ /dev/null @@ -1,12 +0,0 @@ -package apple - -const ( - hostUrl = "https://api.storekit.itunes.apple.com" - sandBoxHostUrl = "https://api.storekit-sandbox.itunes.apple.com" - - // Get Transaction History - getTransactionHistory = "/inApps/v1/history/%s" // originalTransactionId - - // Get All Subscription Statuses - getAllSubscriptionStatuses = "/inApps/v1/subscriptions/%s" // originalTransactionId -) diff --git a/apple/consumption.go b/apple/consumption.go new file mode 100644 index 00000000..5fa1510c --- /dev/null +++ b/apple/consumption.go @@ -0,0 +1,23 @@ +package apple + +import ( + "context" + "fmt" + "net/http" + + "github.com/go-pay/gopay" +) + +// SendConsumptionInformation Send Consumption Information +// Doc: https://developer.apple.com/documentation/appstoreserverapi/send_consumption_information +func (c *Client) SendConsumptionInformation(ctx context.Context, transactionId string, bm gopay.BodyMap) (err error) { + path := fmt.Sprintf(sendConsumptionInformation, transactionId) + res, _, err := c.doRequestPut(ctx, path, bm) + if err != nil { + return err + } + if res.StatusCode != http.StatusOK { + return fmt.Errorf("http.stauts_code = %d", res.StatusCode) + } + return nil +} diff --git a/apple/error.go b/apple/error.go new file mode 100644 index 00000000..826923dc --- /dev/null +++ b/apple/error.go @@ -0,0 +1,31 @@ +package apple + +import "fmt" + +// StatusCodeErr 用于判断Apple的status_code错误 +type StatusCodeErr struct { + ErrorCode int `json:"errorCode,omitempty"` + ErrorMessage string `json:"errorMessage,omitempty"` +} + +// statusCodeErrCheck 检查状态码是否为非200错误 +func statusCodeErrCheck(errRsp StatusCodeErr) error { + if errRsp.ErrorCode != 0 { + return &StatusCodeErr{ + ErrorCode: errRsp.ErrorCode, + ErrorMessage: errRsp.ErrorMessage, + } + } + return nil +} + +func (e *StatusCodeErr) Error() string { + return fmt.Sprintf(`{"errorCode":"%d","errorMessage":"%s"}`, e.ErrorCode, e.ErrorMessage) +} + +func IsStatusCodeError(err error) (*StatusCodeErr, bool) { + if bizErr, ok := err.(*StatusCodeErr); ok { + return bizErr, true + } + return nil, false +} diff --git a/apple/notification_v2.go b/apple/notification_v2.go index 1788db55..632f05cc 100644 --- a/apple/notification_v2.go +++ b/apple/notification_v2.go @@ -1,15 +1,12 @@ package apple import ( - "crypto/ecdsa" - "crypto/x509" - "encoding/base64" + "context" "encoding/json" - "errors" "fmt" - "strings" + "net/http" - "github.com/go-pay/gopay/pkg/jwt" + "github.com/go-pay/gopay" ) // rootPEM is from `openssl x509 -inform der -in AppleRootCA-G3.cer -out apple_root.pem` @@ -31,29 +28,6 @@ at+qIxUCMG1mihDK1A3UT82NQz60imOlM27jbdoXt2QfyFMm+YhidDkLF1vLUagM -----END CERTIFICATE----- ` -// ExtractClaims 解析jws格式数据 -func ExtractClaims(signedPayload string, tran jwt.Claims) (interface{}, error) { - tokenStr := signedPayload - rootCertStr, err := extractHeaderByIndex(tokenStr, 2) - if err != nil { - return nil, err - } - intermediaCertStr, err := extractHeaderByIndex(tokenStr, 1) - if err != nil { - return nil, err - } - if err = verifyCert(rootCertStr, intermediaCertStr); err != nil { - return nil, err - } - _, err = jwt.ParseWithClaims(tokenStr, tran, func(token *jwt.Token) (interface{}, error) { - return extractPublicKeyFromToken(tokenStr) - }) - if err != nil { - return nil, err - } - return tran, nil -} - // DecodeSignedPayload 解析SignedPayload数据 func DecodeSignedPayload(signedPayload string) (payload *NotificationV2Payload, err error) { if signedPayload == "" { @@ -67,72 +41,24 @@ func DecodeSignedPayload(signedPayload string) (payload *NotificationV2Payload, return } -// Per doc: https://datatracker.ietf.org/doc/html/rfc7515#section-4.1.6 -func extractPublicKeyFromToken(tokenStr string) (*ecdsa.PublicKey, error) { - certStr, err := extractHeaderByIndex(tokenStr, 0) - if err != nil { - return nil, err - } - cert, err := x509.ParseCertificate(certStr) - if err != nil { - return nil, err - } - switch pk := cert.PublicKey.(type) { - case *ecdsa.PublicKey: - return pk, nil - default: - return nil, errors.New("appstore public key must be of type ecdsa.PublicKey") - } -} - -func extractHeaderByIndex(tokenStr string, index int) ([]byte, error) { - if index > 2 { - return nil, errors.New("invalid index") - } - tokenArr := strings.Split(tokenStr, ".") - headerByte, err := base64.RawStdEncoding.DecodeString(tokenArr[0]) - if err != nil { - return nil, err - } - type Header struct { - Alg string `json:"alg"` - X5c []string `json:"x5c"` - } - header := &Header{} - err = json.Unmarshal(headerByte, header) +// GetNotificationHistory Get Notification History +// rsp.NotificationHistory[x].SignedPayload use apple.DecodeSignedPayload() to decode +// Doc: https://developer.apple.com/documentation/appstoreserverapi/get_notification_history +func (c *Client) GetNotificationHistory(ctx context.Context, paginationToken string, bm gopay.BodyMap) (rsp *NotificationHistoryRsp, err error) { + path := getNotificationHistory + "?paginationToken=" + paginationToken + res, bs, err := c.doRequestPost(ctx, path, bm) if err != nil { return nil, err } - if len(header.X5c) < index { - return nil, fmt.Errorf("index[%d] > header.x5c slice len(%d)", index, len(header.X5c)) + rsp = &NotificationHistoryRsp{} + if err = json.Unmarshal(bs, rsp); err != nil { + return nil, fmt.Errorf("[%w]: %v, bytes: %s", gopay.UnmarshalErr, err, string(bs)) } - certByte, err := base64.StdEncoding.DecodeString(header.X5c[index]) - if err != nil { - return nil, err - } - return certByte, nil -} - -func verifyCert(certByte, intermediaCertStr []byte) error { - roots := x509.NewCertPool() - ok := roots.AppendCertsFromPEM([]byte(rootPEM)) - if !ok { - return errors.New("failed to parse root certificate") - } - interCert, err := x509.ParseCertificate(intermediaCertStr) - if err != nil { - return errors.New("failed to parse intermedia certificate") - } - intermedia := x509.NewCertPool() - intermedia.AddCert(interCert) - cert, err := x509.ParseCertificate(certByte) - if err != nil { - return err + if res.StatusCode == http.StatusOK { + return rsp, nil } - opts := x509.VerifyOptions{ - Roots: roots, - Intermediates: intermedia, + if err = statusCodeErrCheck(rsp.StatusCodeErr); err != nil { + return rsp, err } - _, err = cert.Verify(opts) - return err + return rsp, nil } diff --git a/apple/notification_v2_model.go b/apple/notification_v2_model.go index 1cac72fa..da7477d8 100644 --- a/apple/notification_v2_model.go +++ b/apple/notification_v2_model.go @@ -138,3 +138,20 @@ type TransactionInfo struct { Type string `json:"type"` WebOrderLineItemId string `json:"webOrderLineItemId"` } + +type NotificationHistoryRsp struct { + StatusCodeErr + HasMore bool `json:"hasMore"` + PaginationToken string `json:"paginationToken"` + NotificationHistory []*NotificationItem `json:"notificationHistory"` +} + +type NotificationItem struct { + SendAttempts []*SendAttemptItem `json:"sendAttempts"` + SignedPayload string `json:"signedPayload"` +} + +type SendAttemptItem struct { + AttemptDate int64 `json:"attemptDate"` + SendAttemptResult string `json:"sendAttemptResult"` +} diff --git a/apple/notification_v2_test.go b/apple/notification_v2_test.go index 7affa077..1c4777f0 100644 --- a/apple/notification_v2_test.go +++ b/apple/notification_v2_test.go @@ -4,6 +4,7 @@ import ( "encoding/json" "testing" + "github.com/go-pay/gopay" "github.com/go-pay/gopay/pkg/xlog" ) @@ -60,6 +61,7 @@ func TestDecodeSignedPayload(t *testing.T) { { "autoRenewProductId":"com.audaos.audarecorder.vip.m2", "autoRenewStatus":1, + "environment":"Sandbox", "expirationIntent":0, "gracePeriodExpiresDate":0, "isInBillingRetryPeriod":false, @@ -68,9 +70,11 @@ func TestDecodeSignedPayload(t *testing.T) { "originalTransactionId":"2000000000842607", "priceIncreaseStatus":0, "productId":"com.audaos.audarecorder.vip.m2", + "recentSubscriptionStartDate":0, "signedDate":1646387008228 } */ + // decode transactionInfo transactionInfo, err := payload.DecodeTransactionInfo() if err != nil { @@ -84,6 +88,7 @@ func TestDecodeSignedPayload(t *testing.T) { { "appAccountToken":"", "bundleId":"com.audaos.audarecorder", + "environment":"Sandbox", "expiresDate":1646387196000, "inAppOwnershipType":"PURCHASED", "isUpgraded":false, @@ -95,7 +100,7 @@ func TestDecodeSignedPayload(t *testing.T) { "purchaseDate":1646387016000, "quantity":1, "revocationDate":0, - "revocationReason":"", + "revocationReason":0, "signedDate":1646387008254, "subscriptionGroupIdentifier":"20929536", "transactionId":"2000000004047119", @@ -104,3 +109,32 @@ func TestDecodeSignedPayload(t *testing.T) { } */ } + +func TestGetNotificationHistory(t *testing.T) { + bm := make(gopay.BodyMap) + bm.Set("startDate", 1646387008228). + Set("endDate", 1646387008228). + Set("notificationType", 1) + // ... + + // 发起请求 + rsp, err := client.GetNotificationHistory(ctx, "xxx", bm) + if err != nil { + if statusErr, ok := IsStatusCodeError(err); ok { + xlog.Errorf("%+v", statusErr) + // do something + return + } + xlog.Errorf("client.GetNotificationHistory(),err:%+v", err) + return + } + for _, v := range rsp.NotificationHistory { + payload, err := DecodeSignedPayload(v.SignedPayload) + if err != nil { + xlog.Errorf("DecodeSignedPayload(),err:+v", err) + continue + } + xlog.Infof("payload: %+v", payload) + // do something others + } +} diff --git a/apple/order.go b/apple/order.go new file mode 100644 index 00000000..ade2e147 --- /dev/null +++ b/apple/order.go @@ -0,0 +1,31 @@ +package apple + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + + "github.com/go-pay/gopay" +) + +// LookUpOrderId Look Up Order ID +// Doc: https://developer.apple.com/documentation/appstoreserverapi/look_up_order_id +func (c *Client) LookUpOrderId(ctx context.Context, orderId string) (rsp *LookUpOrderIdRsp, err error) { + path := fmt.Sprintf(lookUpOrderID, orderId) + res, bs, err := c.doRequestGet(ctx, path) + if err != nil { + return nil, err + } + rsp = &LookUpOrderIdRsp{} + if err = json.Unmarshal(bs, rsp); err != nil { + return rsp, fmt.Errorf("[%w]: %v, bytes: %s", gopay.UnmarshalErr, err, string(bs)) + } + if res.StatusCode == http.StatusOK { + return rsp, nil + } + if err = statusCodeErrCheck(rsp.StatusCodeErr); err != nil { + return rsp, err + } + return rsp, nil +} diff --git a/apple/order_model.go b/apple/order_model.go new file mode 100644 index 00000000..57847006 --- /dev/null +++ b/apple/order_model.go @@ -0,0 +1,7 @@ +package apple + +type LookUpOrderIdRsp struct { + StatusCodeErr + Status int `json:"status,omitempty"` // 0-valid,1-invalid + SignedTransactions []SignedTransaction `json:"signedTransactions,omitempty"` +} diff --git a/apple/order_test.go b/apple/order_test.go new file mode 100644 index 00000000..ad8120bc --- /dev/null +++ b/apple/order_test.go @@ -0,0 +1,36 @@ +package apple + +import ( + "testing" + + "github.com/go-pay/gopay/pkg/xlog" +) + +func TestLookUpOrderId(t *testing.T) { + orderId := "2000000184445477" + rsp, err := client.LookUpOrderId(ctx, orderId) + if err != nil { + if statusErr, ok := IsStatusCodeError(err); ok { + xlog.Errorf("%+v", statusErr) + // do something + return + } + xlog.Errorf("client.LookUpOrderId(),err:%+v", err) + return + } + /** + { + "status":0, + "signedTransactions":[ + "eyJhbGciOiJFUzI1NiIsIng1YyI6WyJNSUlFTURDQ0E3YWdBd0lCQWdJUWFQb1BsZHZwU29FSDBsQnJqRFB2OWpBS0JnZ3Foa2pPUFFRREF6QjFNVVF3UWdZRFZRUURERHRCY0hCc1pTQlhiM0pzWkhkcFpHVWdSR1YyWld4dmNHVnlJRkpsYkdGMGFXOXVjeUJEWlhKMGFXWnBZMkYwYVc5dUlFRjFkR2h2Y21sMGVURUxNQWtHQTFVRUN3d0NSell4RXpBUkJnTlZCQW9NQ2tGd2NHeGxJRWx1WXk0eEN6QUpCZ05WQkFZVEFsVlRNQjRYRFRJeE1EZ3lOVEF5TlRBek5Gb1hEVEl6TURreU5EQXlOVEF6TTFvd2daSXhRREErQmdOVkJBTU1OMUJ5YjJRZ1JVTkRJRTFoWXlCQmNIQWdVM1J2Y21VZ1lXNWtJR2xVZFc1bGN5QlRkRzl5WlNCU1pXTmxhWEIwSUZOcFoyNXBibWN4TERBcUJnTlZCQXNNSTBGd2NHeGxJRmR2Y214a2QybGtaU0JFWlhabGJHOXdaWElnVW1Wc1lYUnBiMjV6TVJNd0VRWURWUVFLREFwQmNIQnNaU0JKYm1NdU1Rc3dDUVlEVlFRR0V3SlZVekJaTUJNR0J5cUdTTTQ5QWdFR0NDcUdTTTQ5QXdFSEEwSUFCT29UY2FQY3BlaXBOTDllUTA2dEN1N3BVY3dkQ1hkTjh2R3FhVWpkNThaOHRMeGlVQzBkQmVBK2V1TVlnZ2gxLzVpQWsrRk14VUZtQTJhMXI0YUNaOFNqZ2dJSU1JSUNCREFNQmdOVkhSTUJBZjhFQWpBQU1COEdBMVVkSXdRWU1CYUFGRDh2bENOUjAxREptaWc5N2JCODVjK2xrR0taTUhBR0NDc0dBUVVGQndFQkJHUXdZakF0QmdnckJnRUZCUWN3QW9ZaGFIUjBjRG92TDJObGNuUnpMbUZ3Y0d4bExtTnZiUzkzZDJSeVp6WXVaR1Z5TURFR0NDc0dBUVVGQnpBQmhpVm9kSFJ3T2k4dmIyTnpjQzVoY0hCc1pTNWpiMjB2YjJOemNEQXpMWGQzWkhKbk5qQXlNSUlCSGdZRFZSMGdCSUlCRlRDQ0FSRXdnZ0VOQmdvcWhraUc5Mk5rQlFZQk1JSCtNSUhEQmdnckJnRUZCUWNDQWpDQnRneUJzMUpsYkdsaGJtTmxJRzl1SUhSb2FYTWdZMlZ5ZEdsbWFXTmhkR1VnWW5rZ1lXNTVJSEJoY25SNUlHRnpjM1Z0WlhNZ1lXTmpaWEIwWVc1alpTQnZaaUIwYUdVZ2RHaGxiaUJoY0hCc2FXTmhZbXhsSUhOMFlXNWtZWEprSUhSbGNtMXpJR0Z1WkNCamIyNWthWFJwYjI1eklHOW1JSFZ6WlN3Z1kyVnlkR2xtYVdOaGRHVWdjRzlzYVdONUlHRnVaQ0JqWlhKMGFXWnBZMkYwYVc5dUlIQnlZV04wYVdObElITjBZWFJsYldWdWRITXVNRFlHQ0NzR0FRVUZCd0lCRmlwb2RIUndPaTh2ZDNkM0xtRndjR3hsTG1OdmJTOWpaWEowYVdacFkyRjBaV0YxZEdodmNtbDBlUzh3SFFZRFZSME9CQllFRkNPQ21NQnEvLzFMNWltdlZtcVgxb0NZZXFyTU1BNEdBMVVkRHdFQi93UUVBd0lIZ0RBUUJnb3Foa2lHOTJOa0Jnc0JCQUlGQURBS0JnZ3Foa2pPUFFRREF3Tm9BREJsQWpFQWw0SkI5R0pIaXhQMm51aWJ5VTFrM3dyaTVwc0dJeFBNRTA1c0ZLcTdoUXV6dmJleUJ1ODJGb3p6eG1ienBvZ29BakJMU0ZsMGRaV0lZbDJlalBWK0RpNWZCbktQdThteW1CUXRvRS9IMmJFUzBxQXM4Yk51ZVUzQ0JqamgxbHduRHNJPSIsIk1JSURGakNDQXB5Z0F3SUJBZ0lVSXNHaFJ3cDBjMm52VTRZU3ljYWZQVGp6Yk5jd0NnWUlLb1pJemowRUF3TXdaekViTUJrR0ExVUVBd3dTUVhCd2JHVWdVbTl2ZENCRFFTQXRJRWN6TVNZd0pBWURWUVFMREIxQmNIQnNaU0JEWlhKMGFXWnBZMkYwYVc5dUlFRjFkR2h2Y21sMGVURVRNQkVHQTFVRUNnd0tRWEJ3YkdVZ1NXNWpMakVMTUFrR0ExVUVCaE1DVlZNd0hoY05NakV3TXpFM01qQXpOekV3V2hjTk16WXdNekU1TURBd01EQXdXakIxTVVRd1FnWURWUVFERER0QmNIQnNaU0JYYjNKc1pIZHBaR1VnUkdWMlpXeHZjR1Z5SUZKbGJHRjBhVzl1Y3lCRFpYSjBhV1pwWTJGMGFXOXVJRUYxZEdodmNtbDBlVEVMTUFrR0ExVUVDd3dDUnpZeEV6QVJCZ05WQkFvTUNrRndjR3hsSUVsdVl5NHhDekFKQmdOVkJBWVRBbFZUTUhZd0VBWUhLb1pJemowQ0FRWUZLNEVFQUNJRFlnQUVic1FLQzk0UHJsV21aWG5YZ3R4emRWSkw4VDBTR1luZ0RSR3BuZ24zTjZQVDhKTUViN0ZEaTRiQm1QaENuWjMvc3E2UEYvY0djS1hXc0w1dk90ZVJoeUo0NXgzQVNQN2NPQithYW85MGZjcHhTdi9FWkZibmlBYk5nWkdoSWhwSW80SDZNSUgzTUJJR0ExVWRFd0VCL3dRSU1BWUJBZjhDQVFBd0h3WURWUjBqQkJnd0ZvQVV1N0Rlb1ZnemlKcWtpcG5ldnIzcnI5ckxKS3N3UmdZSUt3WUJCUVVIQVFFRU9qQTRNRFlHQ0NzR0FRVUZCekFCaGlwb2RIUndPaTh2YjJOemNDNWhjSEJzWlM1amIyMHZiMk56Y0RBekxXRndjR3hsY205dmRHTmhaek13TndZRFZSMGZCREF3TGpBc29DcWdLSVltYUhSMGNEb3ZMMk55YkM1aGNIQnNaUzVqYjIwdllYQndiR1Z5YjI5MFkyRm5NeTVqY213d0hRWURWUjBPQkJZRUZEOHZsQ05SMDFESm1pZzk3YkI4NWMrbGtHS1pNQTRHQTFVZER3RUIvd1FFQXdJQkJqQVFCZ29xaGtpRzkyTmtCZ0lCQkFJRkFEQUtCZ2dxaGtqT1BRUURBd05vQURCbEFqQkFYaFNxNUl5S29nTUNQdHc0OTBCYUI2NzdDYUVHSlh1ZlFCL0VxWkdkNkNTamlDdE9udU1UYlhWWG14eGN4ZmtDTVFEVFNQeGFyWlh2TnJreFUzVGtVTUkzM3l6dkZWVlJUNHd4V0pDOTk0T3NkY1o0K1JHTnNZRHlSNWdtZHIwbkRHZz0iLCJNSUlDUXpDQ0FjbWdBd0lCQWdJSUxjWDhpTkxGUzVVd0NnWUlLb1pJemowRUF3TXdaekViTUJrR0ExVUVBd3dTUVhCd2JHVWdVbTl2ZENCRFFTQXRJRWN6TVNZd0pBWURWUVFMREIxQmNIQnNaU0JEWlhKMGFXWnBZMkYwYVc5dUlFRjFkR2h2Y21sMGVURVRNQkVHQTFVRUNnd0tRWEJ3YkdVZ1NXNWpMakVMTUFrR0ExVUVCaE1DVlZNd0hoY05NVFF3TkRNd01UZ3hPVEEyV2hjTk16a3dORE13TVRneE9UQTJXakJuTVJzd0dRWURWUVFEREJKQmNIQnNaU0JTYjI5MElFTkJJQzBnUnpNeEpqQWtCZ05WQkFzTUhVRndjR3hsSUVObGNuUnBabWxqWVhScGIyNGdRWFYwYUc5eWFYUjVNUk13RVFZRFZRUUtEQXBCY0hCc1pTQkpibU11TVFzd0NRWURWUVFHRXdKVlV6QjJNQkFHQnlxR1NNNDlBZ0VHQlN1QkJBQWlBMklBQkpqcEx6MUFjcVR0a3lKeWdSTWMzUkNWOGNXalRuSGNGQmJaRHVXbUJTcDNaSHRmVGpqVHV4eEV0WC8xSDdZeVlsM0o2WVJiVHpCUEVWb0EvVmhZREtYMUR5eE5CMGNUZGRxWGw1ZHZNVnp0SzUxN0lEdll1VlRaWHBta09sRUtNYU5DTUVBd0hRWURWUjBPQkJZRUZMdXczcUZZTTRpYXBJcVozcjY5NjYvYXl5U3JNQThHQTFVZEV3RUIvd1FGTUFNQkFmOHdEZ1lEVlIwUEFRSC9CQVFEQWdFR01Bb0dDQ3FHU000OUJBTURBMmdBTUdVQ01RQ0Q2Y0hFRmw0YVhUUVkyZTN2OUd3T0FFWkx1Tit5UmhIRkQvM21lb3locG12T3dnUFVuUFdUeG5TNGF0K3FJeFVDTUcxbWloREsxQTNVVDgyTlF6NjBpbU9sTTI3amJkb1h0MlFmeUZNbStZaGlkRGtMRjF2TFVhZ002QmdENTZLeUtBPT0iXX0.eyJ0cmFuc2FjdGlvbklkIjoiMjAwMDAwMDE4NDQ0NTQ3NyIsIm9yaWdpbmFsVHJhbnNhY3Rpb25JZCI6IjIwMDAwMDAxODQ0NDU0NzciLCJ3ZWJPcmRlckxpbmVJdGVtSWQiOiIyMDAwMDAwMDEzNDI5NTU4IiwiYnVuZGxlSWQiOiJDb20uVm9pY2VSZWNvcmRpbmcuVGVsZXBob25lIiwicHJvZHVjdElkIjoiQ29tLlZvaWNlUmVjb3JkaW5nLlRlbGVwaG9uZS4xMDMiLCJzdWJzY3JpcHRpb25Hcm91cElkZW50aWZpZXIiOiIyMTAyNDYxMiIsInB1cmNoYXNlRGF0ZSI6MTY2NjYxNTg3MzAwMCwib3JpZ2luYWxQdXJjaGFzZURhdGUiOjE2NjY2MTU4NzUwMDAsImV4cGlyZXNEYXRlIjoxNjY2NjE1OTkzMDAwLCJxdWFudGl0eSI6MSwidHlwZSI6IkF1dG8tUmVuZXdhYmxlIFN1YnNjcmlwdGlvbiIsImFwcEFjY291bnRUb2tlbiI6IjIwNzI2MmRhLTFhYzgtNGUwYS1hMzk5LTVhYTYyYTgyODAwZiIsImluQXBwT3duZXJzaGlwVHlwZSI6IlBVUkNIQVNFRCIsInNpZ25lZERhdGUiOjE2Njc1NTk2ODU0OTksIm9mZmVyVHlwZSI6MSwiZW52aXJvbm1lbnQiOiJTYW5kYm94In0.HCDFGxo5p1xkl9MWNymeC-K_EMx1P1_3cGsz0-HCybHUvMHRkipie33YZesLZy33PqPL1Nwbb9pkFAtmbvSETQ" + ] + } + */ + if len(rsp.SignedTransactions) == 0 { + xlog.Debugf("rsp:%+v", rsp) + } + for _, v := range rsp.SignedTransactions { + transaction, _ := v.DecodeSignedTransaction() + xlog.Debugf("transactions:%+v", transaction) + } +} diff --git a/apple/refund.go b/apple/refund.go new file mode 100644 index 00000000..4026710f --- /dev/null +++ b/apple/refund.go @@ -0,0 +1,30 @@ +package apple + +import ( + "context" + "encoding/json" + "fmt" + "github.com/go-pay/gopay" + "net/http" +) + +// GetRefundHistory Get Refund History +// Doc: https://developer.apple.com/documentation/appstoreserverapi/get_refund_history +func (c *Client) GetRefundHistory(ctx context.Context, transactionId, revision string) (rsp *RefundHistoryRsp, err error) { + path := fmt.Sprintf(getRefundHistory, transactionId) + "?revision=" + revision + res, bs, err := c.doRequestGet(ctx, path) + if err != nil { + return nil, err + } + rsp = &RefundHistoryRsp{} + if err = json.Unmarshal(bs, rsp); err != nil { + return nil, fmt.Errorf("[%w]: %v, bytes: %s", gopay.UnmarshalErr, err, string(bs)) + } + if res.StatusCode == http.StatusOK { + return rsp, nil + } + if err = statusCodeErrCheck(rsp.StatusCodeErr); err != nil { + return rsp, err + } + return rsp, nil +} diff --git a/apple/refund_model.go b/apple/refund_model.go new file mode 100644 index 00000000..2a40739b --- /dev/null +++ b/apple/refund_model.go @@ -0,0 +1,8 @@ +package apple + +type RefundHistoryRsp struct { + StatusCodeErr + HasMore bool `json:"hasMore"` + Revision string `json:"revision"` + SignedTransactions []SignedTransaction `json:"signedTransactions"` +} diff --git a/apple/refund_test.go b/apple/refund_test.go new file mode 100644 index 00000000..83380e4e --- /dev/null +++ b/apple/refund_test.go @@ -0,0 +1,25 @@ +package apple + +import ( + "testing" + + "github.com/go-pay/gopay/pkg/xlog" +) + +func TestGetRefundHistory(t *testing.T) { + rsp, err := client.GetRefundHistory(ctx, "2000000184445477", "revision") + if err != nil { + if statusErr, ok := IsStatusCodeError(err); ok { + xlog.Errorf("%+v", statusErr) + // do something + return + } + xlog.Errorf("client.GetRefundHistory(),err:%+v", err) + return + } + for _, v := range rsp.SignedTransactions { + transaction, _ := v.DecodeSignedTransaction() + xlog.Debugf("refund transactions:%+v", transaction) + } + +} diff --git a/apple/subscription.go b/apple/subscription.go index 08e274c1..8d169b33 100644 --- a/apple/subscription.go +++ b/apple/subscription.go @@ -7,32 +7,25 @@ import ( "net/http" "github.com/go-pay/gopay" - "github.com/go-pay/gopay/pkg/xhttp" ) -// GetAllSubscriptionStatuses +// GetAllSubscriptionStatuses Get All Subscription Statuses // Doc: https://developer.apple.com/documentation/appstoreserverapi/get_all_subscription_statuses -func GetAllSubscriptionStatuses(ctx context.Context, signConfig *SignConfig, originalTransactionId string, sandbox bool) (rsp *AllSubscriptionStatusesRsp, err error) { - uri := hostUrl + fmt.Sprintf(getAllSubscriptionStatuses, originalTransactionId) - if sandbox { - uri = sandBoxHostUrl + fmt.Sprintf(getAllSubscriptionStatuses, originalTransactionId) - } - token, err := generatingToken(ctx, signConfig) - if err != nil { - return nil, err - } - cli := xhttp.NewClient() - cli.Header.Set("Authorization", "Bearer "+token) - res, bs, err := cli.Type(xhttp.TypeJSON).Get(uri).EndBytes(ctx) +func (c *Client) GetAllSubscriptionStatuses(ctx context.Context, transactionId string) (rsp *AllSubscriptionStatusesRsp, err error) { + path := fmt.Sprintf(getAllSubscriptionStatuses, transactionId) + res, bs, err := c.doRequestGet(ctx, path) if err != nil { return nil, err } - if res.StatusCode != http.StatusOK { - return nil, fmt.Errorf("http.stauts_coud = %d", res.StatusCode) - } rsp = &AllSubscriptionStatusesRsp{} if err = json.Unmarshal(bs, rsp); err != nil { return nil, fmt.Errorf("[%w]: %v, bytes: %s", gopay.UnmarshalErr, err, string(bs)) } - return + if res.StatusCode == http.StatusOK { + return rsp, nil + } + if err = statusCodeErrCheck(rsp.StatusCodeErr); err != nil { + return rsp, err + } + return rsp, nil } diff --git a/apple/subscription_model.go b/apple/subscription_model.go index 86c7936c..5ab4ba9e 100644 --- a/apple/subscription_model.go +++ b/apple/subscription_model.go @@ -3,6 +3,7 @@ package apple import "fmt" type AllSubscriptionStatusesRsp struct { + StatusCodeErr AppAppleId int `json:"appAppleId"` BundleId string `json:"bundleId"` Environment string `json:"environment"` @@ -30,7 +31,7 @@ func (d *LastTransactionsItem) DecodeRenewalInfo() (ri *RenewalInfo, err error) if err != nil { return nil, err } - return + return ri, nil } func (d *LastTransactionsItem) DecodeTransactionInfo() (ti *TransactionInfo, err error) { @@ -42,5 +43,5 @@ func (d *LastTransactionsItem) DecodeTransactionInfo() (ti *TransactionInfo, err if err != nil { return nil, err } - return + return ti, nil } diff --git a/apple/subscription_test.go b/apple/subscription_test.go index a576c20c..ab4a3757 100644 --- a/apple/subscription_test.go +++ b/apple/subscription_test.go @@ -9,20 +9,17 @@ import ( func TestGetAllSubscriptionStatuses(t *testing.T) { originalTransactionId := "2000000184445477" - rsp, err := GetAllSubscriptionStatuses(ctx, &SignConfig{ - IssuerID: "xxxxxxx-b6d3-44da-a777-xxxxxx", - BundleID: "com.xxxxxxx.xxxxx", - AppleKeyID: "xxxxxxxxx", - ApplePrivateKey: `-----BEGIN PRIVATE KEY----- -xxxx ------END PRIVATE KEY-----`, - }, originalTransactionId, true) + rsp, err := client.GetAllSubscriptionStatuses(ctx, originalTransactionId) if err != nil { - xlog.Error(err) + if statusErr, ok := IsStatusCodeError(err); ok { + xlog.Errorf("%+v", statusErr) + // do something + return + } + xlog.Errorf("client.GetAllSubscriptionStatuses(),err:%+v", err) return } /** - response body: { "appAppleId":0, "bundleId":"Com.VoiceRecording.Telephone", diff --git a/apple/token.go b/apple/token.go index d4077e10..7213104f 100644 --- a/apple/token.go +++ b/apple/token.go @@ -1,82 +1,39 @@ package apple import ( - "context" - "crypto/ecdsa" - "crypto/x509" - "encoding/pem" - "errors" "time" "github.com/go-pay/gopay/pkg/jwt" ) -type SignConfig struct { - IssuerID string // 从AppStore Connect 获得的 Issuer ID - BundleID string // app包名 - AppleKeyID string // 内购密钥id - ApplePrivateKey string // 内购密钥.p8文件内容 +type CustomClaims struct { + jwt.Claims + Iss string `json:"iss"` + Iat int64 `json:"iat"` + Exp int64 `json:"exp"` + Aud string `json:"aud"` + Bid string `json:"bid"` } -func generatingToken(ctx context.Context, signConfig *SignConfig) (string, error) { - type CustomClaims struct { - jwt.Claims - Iss string `json:"iss"` - Iat int64 `json:"iat"` - Exp int64 `json:"exp"` - Aud string `json:"aud"` - Bid string `json:"bid"` - } +func (c *Client) generatingToken() (string, error) { claims := CustomClaims{ - Iss: signConfig.IssuerID, + Iss: c.iss, Iat: time.Now().Unix(), Exp: time.Now().Add(5 * time.Minute).Unix(), Aud: "appstoreconnect-v1", - Bid: signConfig.BundleID, + Bid: c.bid, } token := jwt.NewWithClaims(jwt.SigningMethodES256, claims) token.Header = map[string]interface{}{ "alg": "ES256", - "kid": signConfig.AppleKeyID, + "kid": c.kid, "typ": "JWT", } - privateKey, err := ParseECPrivateKeyFromPEM([]byte(signConfig.ApplePrivateKey)) - if err != nil { - return "", err - } - - accessToken, err := token.SignedString(privateKey) + accessToken, err := token.SignedString(c.privateKey) if err != nil { return "", err } return accessToken, nil } - -// ParseECPrivateKeyFromPEM parses a PEM encoded Elliptic Curve Private Key Structure -func ParseECPrivateKeyFromPEM(key []byte) (*ecdsa.PrivateKey, error) { - var err error - - // Parse PEM block - var block *pem.Block - if block, _ = pem.Decode(key); block == nil { - return nil, errors.New("ErrKeyMustBePEMEncoded") - } - - // Parse the key - var parsedKey interface{} - if parsedKey, err = x509.ParseECPrivateKey(block.Bytes); err != nil { - if parsedKey, err = x509.ParsePKCS8PrivateKey(block.Bytes); err != nil { - return nil, err - } - } - - var pkey *ecdsa.PrivateKey - var ok bool - if pkey, ok = parsedKey.(*ecdsa.PrivateKey); !ok { - return nil, errors.New("ErrNotECPrivateKey") - } - - return pkey, nil -} diff --git a/apple/transaction.go b/apple/transaction.go index 0e1e2884..c0c74e97 100644 --- a/apple/transaction.go +++ b/apple/transaction.go @@ -7,32 +7,46 @@ import ( "net/http" "github.com/go-pay/gopay" - "github.com/go-pay/gopay/pkg/xhttp" ) -// GetTransactionHistory +// GetTransactionHistory Get Transaction History // Doc: https://developer.apple.com/documentation/appstoreserverapi/get_transaction_history -func GetTransactionHistory(ctx context.Context, signConfig *SignConfig, originalTransactionId string, bm gopay.BodyMap, sandbox bool) (rsp *TransactionHistoryRsp, err error) { - uri := hostUrl + fmt.Sprintf(getTransactionHistory, originalTransactionId) + "?" + bm.EncodeURLParams() - if sandbox { - uri = sandBoxHostUrl + fmt.Sprintf(getTransactionHistory, originalTransactionId) + "?" + bm.EncodeURLParams() - } - token, err := generatingToken(ctx, signConfig) +func (c *Client) GetTransactionHistory(ctx context.Context, transactionId string, bm gopay.BodyMap) (rsp *TransactionHistoryRsp, err error) { + path := fmt.Sprintf(getTransactionHistory, transactionId) + "?" + bm.EncodeURLParams() + res, bs, err := c.doRequestGet(ctx, path) if err != nil { return nil, err } - cli := xhttp.NewClient() - cli.Header.Set("Authorization", "Bearer "+token) - res, bs, err := cli.Type(xhttp.TypeJSON).Get(uri).EndBytes(ctx) + rsp = &TransactionHistoryRsp{} + if err = json.Unmarshal(bs, rsp); err != nil { + return nil, fmt.Errorf("[%w]: %v, bytes: %s", gopay.UnmarshalErr, err, string(bs)) + } + if res.StatusCode == http.StatusOK { + return rsp, nil + } + if err = statusCodeErrCheck(rsp.StatusCodeErr); err != nil { + return rsp, err + } + return rsp, nil +} + +// GetTransactionInfo Get Transaction Info +// Doc: https://developer.apple.com/documentation/appstoreserverapi/get_transaction_info +func (c *Client) GetTransactionInfo(ctx context.Context, transactionId string) (rsp *TransactionInfoRsp, err error) { + path := fmt.Sprintf(getTransactionInfo, transactionId) + res, bs, err := c.doRequestGet(ctx, path) if err != nil { return nil, err } - if res.StatusCode != http.StatusOK { - return nil, fmt.Errorf("http.stauts_coud = %d", res.StatusCode) - } - rsp = &TransactionHistoryRsp{} + rsp = &TransactionInfoRsp{} if err = json.Unmarshal(bs, rsp); err != nil { return nil, fmt.Errorf("[%w]: %v, bytes: %s", gopay.UnmarshalErr, err, string(bs)) } - return + if res.StatusCode == http.StatusOK { + return rsp, nil + } + if err = statusCodeErrCheck(rsp.StatusCodeErr); err != nil { + return rsp, err + } + return rsp, nil } diff --git a/apple/transaction_model.go b/apple/transaction_model.go index 9efa18cb..5da6cab9 100644 --- a/apple/transaction_model.go +++ b/apple/transaction_model.go @@ -8,9 +8,9 @@ import ( type SignedTransaction string -// TransactionHistoryRsp // Doc: HistoryResponse https://developer.apple.com/documentation/appstoreserverapi/historyresponse type TransactionHistoryRsp struct { + StatusCodeErr AppAppleId int `json:"appAppleId"` BundleId string `json:"bundleId"` Environment string `json:"environment"` @@ -49,5 +49,23 @@ func (s *SignedTransaction) DecodeSignedTransaction() (ti *TransactionsItem, err if err != nil { return nil, err } - return + return ti, nil +} + +// Doc: https://developer.apple.com/documentation/appstoreserverapi/transactioninforesponse +type TransactionInfoRsp struct { + StatusCodeErr + SignedTransactionInfo string `json:"signedTransactionInfo"` +} + +func (t *TransactionInfoRsp) DecodeSignedTransaction() (ti *TransactionsItem, err error) { + if t.SignedTransactionInfo == "" { + return nil, fmt.Errorf("signedTransactionInfo is empty") + } + ti = &TransactionsItem{} + _, err = ExtractClaims(t.SignedTransactionInfo, ti) + if err != nil { + return nil, err + } + return ti, nil } diff --git a/apple/transaction_test.go b/apple/transaction_test.go index 07283dee..14af92f8 100644 --- a/apple/transaction_test.go +++ b/apple/transaction_test.go @@ -13,20 +13,17 @@ func TestGetTransactionHistory(t *testing.T) { "sort": "ASCENDING", } originalTransactionId := "2000000184445477" - rsp, err := GetTransactionHistory(ctx, &SignConfig{ - IssuerID: "xxxxxxx-b6d3-44da-a777-xxxxxx", - BundleID: "com.xxxxxxx.xxxxx", - AppleKeyID: "xxxxxxxxx", - ApplePrivateKey: `-----BEGIN PRIVATE KEY----- -xxxx ------END PRIVATE KEY-----`, - }, originalTransactionId, bm, true) + rsp, err := client.GetTransactionHistory(ctx, originalTransactionId, bm) if err != nil { - xlog.Error(err) + if statusErr, ok := IsStatusCodeError(err); ok { + xlog.Errorf("%+v", statusErr) + // do something + return + } + xlog.Errorf("client.GetTransactionHistory(),err:%+v", err) return } /** - response body: { "appAppleId":0, "bundleId":"Com.VoiceRecording.Telephone", diff --git a/apple/unsign_jwt.go b/apple/unsign_jwt.go new file mode 100644 index 00000000..7f8258c9 --- /dev/null +++ b/apple/unsign_jwt.go @@ -0,0 +1,106 @@ +package apple + +import ( + "crypto/ecdsa" + "crypto/x509" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "strings" + + "github.com/go-pay/gopay/pkg/jwt" +) + +// ExtractClaims 解析jws格式数据 +func ExtractClaims(signedPayload string, tran jwt.Claims) (interface{}, error) { + tokenStr := signedPayload + rootCertStr, err := extractHeaderByIndex(tokenStr, 2) + if err != nil { + return nil, err + } + intermediaCertStr, err := extractHeaderByIndex(tokenStr, 1) + if err != nil { + return nil, err + } + if err = verifyCert(rootCertStr, intermediaCertStr); err != nil { + return nil, err + } + _, err = jwt.ParseWithClaims(tokenStr, tran, func(token *jwt.Token) (interface{}, error) { + return extractPublicKeyFromToken(tokenStr) + }) + if err != nil { + return nil, err + } + return tran, nil +} + +// Per doc: https://datatracker.ietf.org/doc/html/rfc7515#section-4.1.6 +func extractPublicKeyFromToken(tokenStr string) (*ecdsa.PublicKey, error) { + certStr, err := extractHeaderByIndex(tokenStr, 0) + if err != nil { + return nil, err + } + cert, err := x509.ParseCertificate(certStr) + if err != nil { + return nil, err + } + switch pk := cert.PublicKey.(type) { + case *ecdsa.PublicKey: + return pk, nil + default: + return nil, errors.New("appstore public key must be of type ecdsa.PublicKey") + } +} + +func extractHeaderByIndex(tokenStr string, index int) ([]byte, error) { + if index > 2 { + return nil, errors.New("invalid index") + } + tokenArr := strings.Split(tokenStr, ".") + headerByte, err := base64.RawStdEncoding.DecodeString(tokenArr[0]) + if err != nil { + return nil, err + } + type Header struct { + Alg string `json:"alg"` + X5c []string `json:"x5c"` + } + header := &Header{} + err = json.Unmarshal(headerByte, header) + if err != nil { + return nil, err + } + if len(header.X5c) < index { + return nil, fmt.Errorf("index[%d] > header.x5c slice len(%d)", index, len(header.X5c)) + } + certByte, err := base64.StdEncoding.DecodeString(header.X5c[index]) + if err != nil { + return nil, err + } + return certByte, nil +} + +func verifyCert(certByte, intermediaCertStr []byte) error { + roots := x509.NewCertPool() + ok := roots.AppendCertsFromPEM([]byte(rootPEM)) + if !ok { + return errors.New("failed to parse root certificate") + } + interCert, err := x509.ParseCertificate(intermediaCertStr) + if err != nil { + return errors.New("failed to parse intermedia certificate") + } + intermedia := x509.NewCertPool() + intermedia.AddCert(interCert) + cert, err := x509.ParseCertificate(certByte) + if err != nil { + return err + } + opts := x509.VerifyOptions{ + Roots: roots, + Intermediates: intermedia, + } + _, err = cert.Verify(opts) + return err +} diff --git a/apple/unsign_jwt_test.go b/apple/unsign_jwt_test.go new file mode 100644 index 00000000..5b80e23c --- /dev/null +++ b/apple/unsign_jwt_test.go @@ -0,0 +1,34 @@ +package apple + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "fmt" + "testing" + + "github.com/go-pay/gopay/pkg/xlog" +) + +func TestExample(t *testing.T) { + privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + panic(err) + } + xlog.Warnf("%+v", privateKey) + + msg := "hello, world" + hash := sha256.Sum256([]byte(msg)) + + sig, err := ecdsa.SignASN1(rand.Reader, privateKey, hash[:]) + if err != nil { + panic(err) + } + fmt.Printf("signature: %x\n", sig) + fmt.Printf("signature2: %s\n", hex.EncodeToString(sig)) + + valid := ecdsa.VerifyASN1(&privateKey.PublicKey, hash[:], sig) + fmt.Println("signature verified:", valid) +} diff --git a/apple/verify.go b/apple/verify.go index 9be3a863..1787b8ca 100644 --- a/apple/verify.go +++ b/apple/verify.go @@ -9,7 +9,6 @@ import ( const ( // is the URL when testing your app in the sandbox and while your application is in review UrlSandbox = "https://sandbox.itunes.apple.com/verifyReceipt" - // is the URL when your app is live in the App Store UrlProd = "https://buy.itunes.apple.com/verifyReceipt" ) @@ -18,12 +17,12 @@ const ( // url:取 UrlProd 或 UrlSandbox // pwd:苹果APP秘钥,https://help.apple.com/app-store-connect/#/devf341c0f01 // 文档:https://developer.apple.com/documentation/appstorereceipts/verifyreceipt -func VerifyReceipt(ctx context.Context, url, pwd, receipt string) (*VerifyResponse, error) { +func VerifyReceipt(ctx context.Context, url, pwd, receipt string) (rsp *VerifyResponse, err error) { req := &VerifyRequest{Receipt: receipt, Password: pwd} - vr := new(VerifyResponse) - _, err := xhttp.NewClient().Type(xhttp.TypeJSON).Post(url).SendStruct(req).EndStruct(ctx, vr) + rsp = new(VerifyResponse) + _, err = xhttp.NewClient().Type(xhttp.TypeJSON).Post(url).SendStruct(req).EndStruct(ctx, rsp) if err != nil { return nil, err } - return vr, nil + return rsp, nil } diff --git a/apple/model.go b/apple/verify_model.go similarity index 100% rename from apple/model.go rename to apple/verify_model.go diff --git a/apple/verify_test.go b/apple/verify_test.go index 85a8b193..f0bb361f 100644 --- a/apple/verify_test.go +++ b/apple/verify_test.go @@ -1,14 +1,11 @@ package apple import ( - "context" "testing" "github.com/go-pay/gopay/pkg/xlog" ) -var ctx = context.Background() - func TestVerify(t *testing.T) { pwd := "" receipt := "" @@ -18,9 +15,30 @@ func TestVerify(t *testing.T) { return } /** - response body: - { - "receipt":{"original_purchase_date_pst":"2021-08-14 05:28:17 America/Los_Angeles", "purchase_date_ms":"1628944097586", "unique_identifier":"13f339a765b706f8775f729723e9b889b0cbb64e", "original_transaction_id":"1000000859439868", "bvrs":"10", "transaction_id":"1000000859439868", "quantity":"1", "in_app_ownership_type":"PURCHASED", "unique_vendor_identifier":"6DFDEA8B-38CE-4710-A1E1-BAEB8B66FEBD", "item_id":"1581250870", "version_external_identifier":"0", "bid":"com.huochai.main", "is_in_intro_offer_period":"false", "product_id":"10002", "purchase_date":"2021-08-14 12:28:17 Etc/GMT", "is_trial_period":"false", "purchase_date_pst":"2021-08-14 05:28:17 America/Los_Angeles", "original_purchase_date":"2021-08-14 12:28:17 Etc/GMT", "original_purchase_date_ms":"1628944097586"}, "status":0} + { + "receipt":{ + "original_purchase_date_pst":"2021-08-14 05:28:17 America/Los_Angeles", + "purchase_date_ms":"1628944097586", + "unique_identifier":"13f339a765b706f8775f729723e9b889b0cbb64e", + "original_transaction_id":"1000000859439868", + "bvrs":"10", + "transaction_id":"1000000859439868", + "quantity":"1", + "in_app_ownership_type":"PURCHASED", + "unique_vendor_identifier":"6DFDEA8B-38CE-4710-A1E1-BAEB8B66FEBD", + "item_id":"1581250870", + "version_external_identifier":"0", + "bid":"com.huochai.main", + "is_in_intro_offer_period":"false", + "product_id":"10002", + "purchase_date":"2021-08-14 12:28:17 Etc/GMT", + "is_trial_period":"false", + "purchase_date_pst":"2021-08-14 05:28:17 America/Los_Angeles", + "original_purchase_date":"2021-08-14 12:28:17 Etc/GMT", + "original_purchase_date_ms":"1628944097586" + }, + "status":0 + } */ if rsp.Receipt != nil { xlog.Debugf("receipt:%+v", rsp.Receipt) diff --git a/doc/apple.md b/doc/apple.md index 6d657371..6a9ab3a7 100644 --- a/doc/apple.md +++ b/doc/apple.md @@ -1,6 +1,32 @@ ## Apple -## Apple Pay 支付校验收据 +- App Store Server API:[官方文档](https://developer.apple.com/documentation/appstoreserverapi) + +### 初始化Apple客户端 + +> 具体介绍,请参考 `gopay/apple/client_test.go` + +```go +import ( + "github.com/go-pay/gopay/pkg/xlog" + "github.com/go-pay/gopay/apple" +) + +// 初始化通联客户端 +// iss:issuer ID +// bid:bundle ID +// kid:private key ID +// privateKey:私钥文件读取后的字符串内容 +// isProd:是否是正式环境 +client, err = NewClient(iss, bid, kid, "privateKey", false) +if err != nil { + xlog.Error(err) + return +} +``` + + +### Apple Pay 支付校验收据 * [苹果校验收据文档](https://developer.apple.com/documentation/appstorereceipts/verifyreceipt) @@ -138,7 +164,18 @@ xlog.Color(xlog.YellowBright).Info(string(bs2)) */ ``` -### App Store Server API +### App Store Server API Client Function + +* `client.GetTransactionInfo()` => Get Transaction Info +* `client.GetTransactionHistory()` => Get Transaction History +* `client.GetAllSubscriptionStatuses()` => GetAllSubscriptionStatuses +* `client.SendConsumptionInformation()` => Send Consumption Information +* `client.GetNotificationHistory()` => Get Notification History +* `client.LookUpOrderId()` => Look Up Order ID +* `client.GetRefundHistory()` => Get Refund History + +### Apple Function -* `apple.GetTransactionHistory()` => Get Transaction History -* `apple.GetAllSubscriptionStatuses()` => GetAllSubscriptionStatuses \ No newline at end of file +* `apple.VerifyReceipt()` => 验证支付凭证 +* `apple.ExtractClaims()` => 解析signedPayload +* `apple.DecodeSignedPayload()` => 解析notification signedPayload \ No newline at end of file diff --git a/error.go b/error.go index 67712b21..4e6d336c 100644 --- a/error.go +++ b/error.go @@ -6,6 +6,7 @@ var ( MissWechatInitParamErr = errors.New("missing wechat init parameter") MissAlipayInitParamErr = errors.New("missing alipay init parameter") MissPayPalInitParamErr = errors.New("missing paypal init parameter") + MissAppleInitParamErr = errors.New("missing apple init parameter") MissParamErr = errors.New("missing required parameter") MarshalErr = errors.New("marshal error") UnmarshalErr = errors.New("unmarshal error") diff --git a/paypal/client.go b/paypal/client.go index c5bc1f08..f8ed73c7 100644 --- a/paypal/client.go +++ b/paypal/client.go @@ -11,7 +11,7 @@ import ( "github.com/go-pay/gopay/pkg/xlog" ) -// Client PayPal支付客 +// Client PayPal支付客户端 type Client struct { Clientid string Secret string