From d8a11cb847559196033a2a39a4d6a364cbfed998 Mon Sep 17 00:00:00 2001 From: Mo Firouz Date: Wed, 5 Jul 2017 11:57:51 +0100 Subject: [PATCH] Add In-App Purchase Validation. Merged #89 --- CHANGELOG.md | 1 + glide.lock | 29 +- glide.yaml | 2 + main.go | 3 +- migrations/20170620104217_storage_list.sql | 19 ++ pkg/iap/apple.go | 194 +++++++++++++ pkg/iap/apple_test.go | 256 ++++++++++++++++ pkg/iap/google.go | 162 +++++++++++ pkg/iap/google_test.go | 199 +++++++++++++ pkg/iap/model.go | 109 +++++++ server/api.proto | 55 ++++ server/config.go | 33 +++ server/core_purchase.go | 159 ++++++++++ server/pipeline.go | 12 +- server/pipeline_matchmake.go | 14 + server/pipeline_purchase.go | 108 +++++++ tests/core_purchase_test.go | 321 +++++++++++++++++++++ 17 files changed, 1671 insertions(+), 5 deletions(-) create mode 100644 pkg/iap/apple.go create mode 100644 pkg/iap/apple_test.go create mode 100644 pkg/iap/google.go create mode 100644 pkg/iap/google_test.go create mode 100644 pkg/iap/model.go create mode 100644 server/core_purchase.go create mode 100644 server/pipeline_purchase.go create mode 100644 tests/core_purchase_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 76e818aa4..b71228340 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ The format is based on [keep a changelog](http://keepachangelog.com/) and this p ### Added - New storage list feature. - Ban users and create groups from within the Runtime environment. +- New In-App Purchase validation feature. ### Changed - Run Facebook friends import after registration completes. diff --git a/glide.lock b/glide.lock index 0ba6a4424..f32a9d0e1 100644 --- a/glide.lock +++ b/glide.lock @@ -1,6 +1,10 @@ -hash: 00c4318ad08f168ebf525164ff9fb486692a8f72905ef5d514bfe41bb006fb71 -updated: 2017-06-29T10:26:44.307843698+01:00 +hash: ff9abb7b8ba5a48c7ca600b2e89bc295be3d62101e22ef8ed817b7114a3015a7 +updated: 2017-07-09T16:20:42.443074055+01:00 imports: +- name: cloud.google.com/go + version: 934866196d63fdcd8291ef6f16e70f4a830cda22 + subpackages: + - compute/metadata - name: github.com/armon/go-metrics version: 97c69685293dce4c0a2d0b19535179bbc976e4d2 - name: github.com/dgrijalva/jwt-go @@ -26,7 +30,6 @@ imports: version: 8ee79997227bf9b34611aee7946ae64735e6fd93 subpackages: - proto - - protoc-gen-go/descriptor - name: github.com/gorhill/cronexpr version: a557574d6c024ed6e36acc8b610f5f211c91568a - name: github.com/gorilla/context @@ -73,6 +76,26 @@ imports: version: 69d4b8aa71caaaa75c3dfc11211d1be495abec7c subpackages: - context + - context/ctxhttp +- name: golang.org/x/oauth2 + version: cce311a261e6fcf29de72ca96827bdb0b7d9c9e6 + subpackages: + - google + - internal + - jws + - jwt +- name: google.golang.org/appengine + version: ad2570cd3913654e00c5f0183b39d2f998e54046 + subpackages: + - internal + - internal/app_identity + - internal/base + - internal/datastore + - internal/log + - internal/modules + - internal/remote_api + - internal/urlfetch + - urlfetch - name: gopkg.in/gorp.v1 version: c87af80f3cc5036b55b83d77171e156791085e2e testImports: diff --git a/glide.yaml b/glide.yaml index 15fcd8d4a..03330245d 100644 --- a/glide.yaml +++ b/glide.yaml @@ -15,6 +15,8 @@ import: - package: golang.org/x/crypto subpackages: - bcrypt +- package: golang.org/x/oauth2/google +- package: cloud.google.com/go/compute/metadata - package: github.com/golang/protobuf - package: github.com/gogo/protobuf version: ~0.4.0 diff --git a/main.go b/main.go index 08830f813..37eb3f20c 100644 --- a/main.go +++ b/main.go @@ -99,7 +99,8 @@ func main() { } socialClient := social.NewClient(5 * time.Second) - pipeline := server.NewPipeline(config, db, trackerService, matchmakerService, messageRouter, sessionRegistry, socialClient, runtime) + purchaseService := server.NewPurchaseService(jsonLogger, multiLogger, db, config.GetPurchase()) + pipeline := server.NewPipeline(config, db, trackerService, matchmakerService, messageRouter, sessionRegistry, socialClient, runtime, purchaseService) authService := server.NewAuthenticationService(jsonLogger, config, db, statsService, sessionRegistry, socialClient, pipeline, runtime) dashboardService := server.NewDashboardService(jsonLogger, multiLogger, semver, config, statsService) diff --git a/migrations/20170620104217_storage_list.sql b/migrations/20170620104217_storage_list.sql index 717b8fa88..194f45a31 100644 --- a/migrations/20170620104217_storage_list.sql +++ b/migrations/20170620104217_storage_list.sql @@ -33,4 +33,23 @@ CREATE INDEX IF NOT EXISTS deleted_at_user_id_bucket_collection_read_record_idx CREATE INDEX IF NOT EXISTS deleted_at_bucket_read_collection_record_user_id_idx ON storage (deleted_at, bucket, read, collection, record, user_id); CREATE INDEX IF NOT EXISTS deleted_at_bucket_collection_read_record_user_id_idx ON storage (deleted_at, bucket, collection, read, record, user_id); +CREATE TABLE IF NOT EXISTS purchase ( + PRIMARY KEY (user_id, provider, receipt_id), -- ad-hoc purchase lookup + user_id BYTEA NOT NULL, + provider SMALLINT NOT NULL, -- google(0), apple(1) + product_id VARCHAR(255) NOT NULL, + receipt_id VARCHAR(255) NOT NULL, -- the transaction ID + receipt BYTEA NOT NULL, + provider_resp BYTEA NOT NULL, + created_at BIGINT CHECK (created_at > 0) NOT NULL +); + +-- look up purchase by ID and retrieve user. This must be unique. +CREATE UNIQUE INDEX IF NOT EXISTS purchase_provider_receipt_id_user_id_idx ON purchase (provider, receipt_id, user_id); +-- list purchases by user +CREATE INDEX IF NOT EXISTS purchase_user_id_created_at_provider_receipt_id_idx ON purchase (user_id, created_at, provider, receipt_id); +-- list purchases by most recent timestamp +CREATE INDEX IF NOT EXISTS purchase_created_at_user_id_provider_receipt_id_idx ON purchase (created_at, user_id, provider, receipt_id); + -- +migrate Down +DROP TABLE IF EXISTS purchase; diff --git a/pkg/iap/apple.go b/pkg/iap/apple.go new file mode 100644 index 000000000..8640eb777 --- /dev/null +++ b/pkg/iap/apple.go @@ -0,0 +1,194 @@ +// Copyright 2017 The Nakama Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package iap + +import ( + "encoding/json" + "io/ioutil" + "net/http" + "strings" + "time" + + "errors" +) + +const ( + APPLE_ENV_PRODUCTION = "https://buy.itunes.apple.com/verifyReceipt" + APPLE_ENV_SANDBOX = "https://sandbox.itunes.apple.com/verifyReceipt" +) + +const ( + APPLE_VALID = 0 + APPLE_UNREADABLE_JSON = 21000 + APPLE_MALFORMED_DATA = 21002 + APPLE_AUTHENTICATION_ERROR = 21003 + APPLE_UNMATCHED_SECRET = 21004 + APPLE_SERVER_UNAVAILABLE = 21005 + APPLE_SUBSCRIPTION_EXPIRED = 21006 + APPLE_SANDBOX_RECEIPT_ON_PROD = 21007 + APPLE_PROD_RECEIPT_ON_SANDBOX = 21008 +) + +type AppleClient struct { + client *http.Client + password string + production bool +} + +func NewAppleClient(password string, production bool, timeout int) (*AppleClient, error) { + ac := &AppleClient{ + password: password, + production: production, + } + err := ac.init(production) + if err != nil { + return nil, err + } + + ac.client = &http.Client{Timeout: time.Duration(int64(timeout)) * time.Millisecond} + return ac, nil +} + +func NewAppleClientWithHTTP(password string, production bool, httpClient *http.Client) (*AppleClient, error) { + ac := &AppleClient{ + password: password, + production: production, + } + err := ac.init(production) + if err != nil { + return nil, err + } + + ac.client = httpClient + + return ac, nil +} + +func (ac *AppleClient) init(production bool) error { + if ac.password == "" { + return errors.New("Apple in-app purchase configuration is inactive. Reason: Missing password.") + } + + return nil +} + +func (ac *AppleClient) Verify(p *ApplePurchase) (*PurchaseVerifyResponse, *AppleReceipt) { + payload, _ := json.Marshal(&AppleRequest{ + ReceiptData: p.ReceiptData, + Password: ac.password, + }) + return ac.verify(payload, p, ac.production, false) +} + +func (ac *AppleClient) verify(payload []byte, p *ApplePurchase, production bool, retrying bool) (*PurchaseVerifyResponse, *AppleReceipt) { + r := &PurchaseVerifyResponse{} + + env := APPLE_ENV_SANDBOX + if production { + env = APPLE_ENV_PRODUCTION + } + + resp, err := ac.client.Post(env, CONTENT_TYPE_APP_JSON, strings.NewReader(string(payload))) + if err != nil { + r.Message = errors.New("Could not connect to Apple verification service.") + return r, nil + } + + defer resp.Body.Close() + body, err := ioutil.ReadAll(resp.Body) + if err != nil { + r.Message = errors.New("Could not read response from Apple verification service.") + return r, nil + } + + appleResp := &AppleResponse{} + if err = json.Unmarshal(body, &appleResp); err != nil { + r.Message = errors.New("Could not parse response from Apple verification service.") + return r, nil + } + + r.PurchaseProviderReachable = true + r.Data = string(body) + + if reason := ac.checkStatus(appleResp); reason != "" { + if appleResp.Status == APPLE_SANDBOX_RECEIPT_ON_PROD && !retrying { + return ac.verify(payload, p, false, true) + } else if appleResp.Status == APPLE_PROD_RECEIPT_ON_SANDBOX && !retrying { + return ac.verify(payload, p, true, true) + } + + r.Message = errors.New(reason) + return r, nil + } + + if reason := ac.checkReceipt(p.ProductId, appleResp.Receipt); reason != "" { + r.Message = errors.New(reason) + return r, nil + } + + r.Success = true + r.Message = nil + return r, appleResp.Receipt +} + +func (ac *AppleClient) checkStatus(a *AppleResponse) string { + switch a.Status { + case APPLE_VALID: + return "" + case APPLE_UNREADABLE_JSON: + return "Apple could not read the receipt." + case APPLE_MALFORMED_DATA: + return "Receipt was malformed." + case APPLE_AUTHENTICATION_ERROR: + return "The receipt could not be validated." + case APPLE_UNMATCHED_SECRET: + return "Apple Purchase password is invalid." + case APPLE_SERVER_UNAVAILABLE: + return "Apple purchase verification servers are not currently available." + case APPLE_SUBSCRIPTION_EXPIRED: + return "This receipt is valid but the subscription has expired." + case APPLE_SANDBOX_RECEIPT_ON_PROD: + return "This receipt is a sandbox receipt, but it was sent to the production service for verification." + case APPLE_PROD_RECEIPT_ON_SANDBOX: + return "This receipt is a production receipt, but it was sent to the sandbox service for verification." + default: + return "An unknown error occurred" + } +} + +func (ac *AppleClient) checkReceipt(productId string, receipt *AppleReceipt) string { + // This only support receipts in iOS 7+ + + if len(receipt.InApp) < 1 { + return "No in-app purchase receipts were found" + } + + //TODO: Improvement - Process more than one in-app receipts + a := receipt.InApp[0] + if productId != a.ProductID { + return "Product ID does not match receipt" + } + + // Treat a canceled receipt the same as if no purchase had ever been made. + if len(a.CancellationDate) > 0 { + return "Purchase has been cancelled: " + a.CancellationDate + } + + if len(a.ExpiresDate) > 0 { + return "Purchase is a subscription that expired: " + a.ExpiresDate + } + + return "" // valid +} diff --git a/pkg/iap/apple_test.go b/pkg/iap/apple_test.go new file mode 100644 index 000000000..c19552b6e --- /dev/null +++ b/pkg/iap/apple_test.go @@ -0,0 +1,256 @@ +// Copyright 2017 The Nakama Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package iap + +import ( + "bytes" + "encoding/json" + "io/ioutil" + "net/http" + "testing" + "errors" + "fmt" +) + +const ( + TEST_APPLE_PRODUCT_ID = "com.heroiclabs.iap.apple" +) + +type roundTripperFunc func(*http.Request) (*http.Response, error) +func (fn roundTripperFunc) RoundTrip(req *http.Request) (*http.Response, error) { + return fn(req) +} + + +func assertEquals(t *testing.T, expected, reality string) { + if expected != reality { + t.Fatal(fmt.Sprintf("Assertion failed. \"%s\" is not same as \"%s\"", expected, reality)) + } +} + +// ---- + +func setupAppleClient(ar *AppleResponse) *AppleClient { + a, _ := json.Marshal(ar) + return &AppleClient{ + production: true, + client: &http.Client{Transport: roundTripperFunc(func(req *http.Request) (*http.Response, error) { + resp := &http.Response{ + StatusCode: 200, + Body: ioutil.NopCloser(bytes.NewReader(a)), + } + return resp, nil + })}, + } +} + +func TestApplePurchaseProviderUnavailable(t *testing.T) { + ac := &AppleClient{ + production: true, + client: &http.Client{Transport: roundTripperFunc(func(req *http.Request) (*http.Response, error) { + return nil, errors.New("Could not connect to Apple verification service.") + })}, + } + + r, _ := ac.Verify(&ApplePurchase{ + ProductId: TEST_APPLE_PRODUCT_ID, + }) + + if r.Success || r.Message == nil { + t.Fatal("Apple purchase should not have been successful.") + } + + if r.PurchaseProviderReachable { + t.Fatal("Apple purchase provider should NOT have been reachable.") + } + + assertEquals(t, "Could not connect to Apple verification service.", r.Message.Error()) +} + +func TestApplePurchaseProviderInvalidResponse(t *testing.T) { + ac := &AppleClient{ + production: true, + client: &http.Client{Transport: roundTripperFunc(func(req *http.Request) (*http.Response, error) { + resp := &http.Response{ + StatusCode: 400, + Body: ioutil.NopCloser(bytes.NewReader([]byte("invalid json response from Apple"))), + } + return resp, nil + })}, + } + + r, _ := ac.Verify(&ApplePurchase{ + ProductId: TEST_APPLE_PRODUCT_ID, + }) + + if r.Success || r.Message == nil { + t.Fatal("Apple purchase should not have been successful.") + } + + if r.PurchaseProviderReachable { + t.Fatal("Apple purchase provider should NOT have been reachable.") + } + + assertEquals(t, "Could not parse response from Apple verification service.", r.Message.Error()) +} + +func TestInvalidStatus(t *testing.T) { + ac := setupAppleClient(&AppleResponse{ + Status: APPLE_AUTHENTICATION_ERROR, + }) + + r, _ := ac.Verify(&ApplePurchase{ + ProductId: TEST_APPLE_PRODUCT_ID, + }) + + if r.Success || r.Message == nil { + t.Fatal("Apple purchase should not have been successful.") + } + + if !r.PurchaseProviderReachable { + t.Fatal("Apple purchase provider should have been reachable.") + } + + assertEquals(t, "The receipt could not be validated.", r.Message.Error()) +} + +func TestNoInAppReceipt(t *testing.T) { + ac := setupAppleClient(&AppleResponse{ + Status: APPLE_VALID, + Receipt: &AppleReceipt{}, + }) + + r, _ := ac.Verify(&ApplePurchase{ + ProductId: TEST_APPLE_PRODUCT_ID, + }) + + if r.Success || r.Message == nil { + t.Fatal("Apple purchase should not have been successful.") + } + + if !r.PurchaseProviderReachable { + t.Fatal("Apple purchase provider should have been reachable.") + } + + assertEquals(t, "No in-app purchase receipts were found", r.Message.Error()) +} + +func TestUnmatchingProductID(t *testing.T) { + ac := setupAppleClient(&AppleResponse{ + Status: APPLE_VALID, + Receipt: &AppleReceipt{ + InApp: []*AppleInAppReceipt{ + &AppleInAppReceipt{ + ProductID: "bad-product-id", + }, + }, + }, + }) + + r, _ := ac.Verify(&ApplePurchase{ + ProductId: TEST_APPLE_PRODUCT_ID, + }) + + if r.Success || r.Message == nil { + t.Fatal("Apple purchase should not have been successful.") + } + + if !r.PurchaseProviderReachable { + t.Fatal("Apple purchase provider should have been reachable.") + } + + assertEquals(t, "Product ID does not match receipt", r.Message.Error()) +} + +func TestCancelledPurchase(t *testing.T) { + ac := setupAppleClient(&AppleResponse{ + Status: APPLE_VALID, + Receipt: &AppleReceipt{ + InApp: []*AppleInAppReceipt{ + &AppleInAppReceipt{ + ProductID: TEST_APPLE_PRODUCT_ID, + CancellationDate: "123", + }, + }, + }, + }) + + r, _ := ac.Verify(&ApplePurchase{ + ProductId: TEST_APPLE_PRODUCT_ID, + }) + + if r.Success || r.Message == nil { + t.Fatal("Apple purchase should not have been successful.") + } + + if !r.PurchaseProviderReachable { + t.Fatal("Apple purchase provider should have been reachable.") + } + + assertEquals(t, "Purchase has been cancelled: 123", r.Message.Error()) +} + +func TestExpiredPurchase(t *testing.T) { + ac := setupAppleClient(&AppleResponse{ + Status: APPLE_VALID, + Receipt: &AppleReceipt{ + InApp: []*AppleInAppReceipt{ + &AppleInAppReceipt{ + ProductID: TEST_APPLE_PRODUCT_ID, + ExpiresDate: "123", + }, + }, + }, + }) + + r, _ := ac.Verify(&ApplePurchase{ + ProductId: TEST_APPLE_PRODUCT_ID, + }) + + if r.Success || r.Message == nil { + t.Fatal("Apple purchase should not have been successful.") + } + + if !r.PurchaseProviderReachable { + t.Fatal("Apple purchase provider should have been reachable.") + } + + assertEquals(t, "Purchase is a subscription that expired: 123", r.Message.Error()) +} + +func TestValidPurchase(t *testing.T) { + ac := setupAppleClient(&AppleResponse{ + Status: APPLE_VALID, + Receipt: &AppleReceipt{ + InApp: []*AppleInAppReceipt{ + &AppleInAppReceipt{ + ProductID: TEST_APPLE_PRODUCT_ID, + }, + }, + }, + }) + + r, _ := ac.Verify(&ApplePurchase{ + ProductId: TEST_APPLE_PRODUCT_ID, + }) + + if !r.PurchaseProviderReachable { + t.Fatal("Apple purchase provider should have been reachable.") + } + + if !r.Success || r.Message != nil { + t.Fatal("Apple purchase should have been successful.") + } +} diff --git a/pkg/iap/google.go b/pkg/iap/google.go new file mode 100644 index 000000000..b2cfea91c --- /dev/null +++ b/pkg/iap/google.go @@ -0,0 +1,162 @@ +// Copyright 2017 The Nakama Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package iap + +import ( + "context" + "io/ioutil" + "net/http" + + "errors" + + "encoding/json" + + "fmt" + + "time" + + "golang.org/x/oauth2/google" +) + +const ( + GOOGLE_IAP_SCOPE = "https://www.googleapis.com/auth/androidpublisher" + GOOGLE_IAP_URL = "https://www.googleapis.com/androidpublisher/v2/applications/%s/purchases/%s/%s/tokens/%s" +) + +type GoogleClient struct { + client *http.Client + packageName string + serviceKeyFilePath string +} + +func NewGoogleClient(packageName string, serviceKeyFilePath string, timeout int) (*GoogleClient, error) { + gc := &GoogleClient{ + packageName: packageName, + serviceKeyFilePath: serviceKeyFilePath, + } + + if gc.packageName == "" { + return nil, errors.New("Google in-app purchase configuration is inactive. Reason: Missing package name.") + } + if gc.serviceKeyFilePath == "" { + return nil, errors.New("Google in-app purchase configuration is inactive. Reason: Missing service account key.") + } + + jsonContent, err := ioutil.ReadFile(gc.serviceKeyFilePath) + if err != nil { + return nil, errors.New("Google in-app purchase configuration is inactive. Reason: Failed to read Google service account key.") + } + + config, err := google.JWTConfigFromJSON(jsonContent, GOOGLE_IAP_SCOPE) + if err != nil { + return nil, errors.New("Google in-app purchase configuration is inactive. Reason: Failed to parse Google service account key.") + } + + gc.client = config.Client(context.Background()) + gc.client.Timeout = time.Duration(int64(timeout)) * time.Millisecond + return gc, nil +} + +func NewGoogleClientWithHTTP(packageName string, httpClient *http.Client) (*GoogleClient, error) { + if packageName == "" { + return nil, errors.New("Google in-app purchase configuration is inactive. Reason: Missing package name.") + } + + gc := &GoogleClient{ + packageName: packageName, + client: httpClient, + } + return gc, nil +} + +func (gc *GoogleClient) VerifyProduct(p *GooglePurchase) (*PurchaseVerifyResponse, *GoogleProductReceipt) { + r := &PurchaseVerifyResponse{} + + body, err := gc.sendGoogleRequest(p) + if err != nil { + r.Message = err + return r, nil + } + + googleProductResp := &GoogleProductReceipt{} + if err = json.Unmarshal(body, &googleProductResp); err != nil { + r.Message = errors.New("Could not parse product response from Google verification service.") + return r, nil + } + + r.PurchaseProviderReachable = true + r.Data = string(body) + + // 0=Purchased, 1=Cancelled + if googleProductResp.PurchaseState != 0 { + r.Message = errors.New("Purchase has been voided or cancelled.") + return r, nil + } + + // 0=Yet to be consumed, 1=Consumed + if googleProductResp.ConsumptionState != 0 { + r.Message = errors.New("Purchase has already been consumed.") + return r, nil + } + + r.Success = true + r.Message = nil + return r, googleProductResp +} + +func (gc *GoogleClient) VerifySubscription(p *GooglePurchase) (*PurchaseVerifyResponse, *GoogleSubscriptionReceipt) { + r := &PurchaseVerifyResponse{} + + body, err := gc.sendGoogleRequest(p) + if err != nil { + r.Message = err + return r, nil + } + + googleSubscriptionResp := &GoogleSubscriptionReceipt{} + if err = json.Unmarshal(body, &googleSubscriptionResp); err != nil { + r.Message = errors.New("Could not parse subscription response from Google verification service.") + return r, nil + } + + r.PurchaseProviderReachable = true + r.Data = string(body) + + nowEpoch := time.Now().UnixNano() / 1000000 + + if googleSubscriptionResp.ExpiryTimeMillis < nowEpoch { + r.Message = errors.New("Purchase is a subscription that expired.") + return r, nil + } + + r.Success = true + r.Message = nil + return r, googleSubscriptionResp +} + +func (gc *GoogleClient) sendGoogleRequest(p *GooglePurchase) ([]byte, error) { + url := fmt.Sprintf(GOOGLE_IAP_URL, gc.packageName, p.ProductType, p.ProductId, p.PurchaseToken) + resp, err := gc.client.Post(url, CONTENT_TYPE_APP_JSON, nil) + if err != nil { + return nil, errors.New("Could not connect to Google verification service.") + } + + defer resp.Body.Close() + body, err := ioutil.ReadAll(resp.Body) + if err != nil { + return nil, errors.New("Could not read response from Google verification service.") + } + return body, nil +} diff --git a/pkg/iap/google_test.go b/pkg/iap/google_test.go new file mode 100644 index 000000000..5744de81e --- /dev/null +++ b/pkg/iap/google_test.go @@ -0,0 +1,199 @@ +// Copyright 2017 The Nakama Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package iap + +import ( + "bytes" + "encoding/json" + "errors" + "io/ioutil" + "net/http" + "testing" + "time" +) + +const ( + TEST_GOOGLE_PRODUCT_ID = "com.heroiclabs.iap.google" +) + +func setupGoogleClient(googleReceipt interface{}) *GoogleClient { + g, _ := json.Marshal(googleReceipt) + return &GoogleClient{ + packageName: "com.heroiclabs.iap.google.packagename", + client: &http.Client{Transport: roundTripperFunc(func(req *http.Request) (*http.Response, error) { + resp := &http.Response{ + StatusCode: 200, + Body: ioutil.NopCloser(bytes.NewReader(g)), + } + return resp, nil + })}, + } +} + +func TestGooglePurchaseProviderUnavailable(t *testing.T) { + gc := &GoogleClient{ + packageName: "com.heroiclabs.iap.google.packagename", + client: &http.Client{Transport: roundTripperFunc(func(req *http.Request) (*http.Response, error) { + return nil, errors.New("Could not connect to Google verification service.") + })}, + } + + r, _ := gc.VerifyProduct(&GooglePurchase{ + ProductType: "product", + ProductId: TEST_GOOGLE_PRODUCT_ID, + }) + + if r.Success || r.Message == nil { + t.Fatal("Google purchase should not have been successful.") + } + + if r.PurchaseProviderReachable { + t.Fatal("Google purchase provider should NOT have been reachable.") + } + + assertEquals(t, "Could not connect to Google verification service.", r.Message.Error()) +} + +func TestGooglePurchaseProviderInvalidResponse(t *testing.T) { + gc := &GoogleClient{ + packageName: "com.heroiclabs.iap.google.packagename", + client: &http.Client{Transport: roundTripperFunc(func(req *http.Request) (*http.Response, error) { + resp := &http.Response{ + StatusCode: 400, + Body: ioutil.NopCloser(bytes.NewReader([]byte("invalid json response from Google"))), + } + return resp, nil + })}, + } + + r, _ := gc.VerifyProduct(&GooglePurchase{ + ProductType: "product", + ProductId: TEST_GOOGLE_PRODUCT_ID, + }) + + if r.Success || r.Message == nil { + t.Fatal("Google purchase should not have been successful.") + } + + if r.PurchaseProviderReachable { + t.Fatal("Google purchase provider should NOT have been reachable.") + } + + assertEquals(t, "Could not parse product response from Google verification service.", r.Message.Error()) +} + +func TestGoogleProductCancelled(t *testing.T) { + gc := setupGoogleClient(&GoogleProductReceipt{ + PurchaseState: 1, + ConsumptionState: 0, + }) + + r, _ := gc.VerifyProduct(&GooglePurchase{ + ProductType: "product", + ProductId: TEST_GOOGLE_PRODUCT_ID, + }) + + if r.Success || r.Message == nil { + t.Fatal("Google purchase should not have been successful.") + } + + if !r.PurchaseProviderReachable { + t.Fatal("Google purchase provider should have been reachable.") + } + + assertEquals(t, "Purchase has been voided or cancelled.", r.Message.Error()) +} + +func TestGoogleProductConsumed(t *testing.T) { + gc := setupGoogleClient(&GoogleProductReceipt{ + PurchaseState: 0, + ConsumptionState: 1, + }) + + r, _ := gc.VerifyProduct(&GooglePurchase{ + ProductType: "product", + ProductId: TEST_GOOGLE_PRODUCT_ID, + }) + + if r.Success || r.Message == nil { + t.Fatal("Google purchase should not have been successful.") + } + + if !r.PurchaseProviderReachable { + t.Fatal("Google purchase provider should have been reachable.") + } + + assertEquals(t, "Purchase has already been consumed.", r.Message.Error()) +} + +func TestGoogleSubscriptionExpired(t *testing.T) { + gc := setupGoogleClient(&GoogleSubscriptionReceipt{ + ExpiryTimeMillis: (time.Now().UnixNano() / 1000000) - 10, + }) + + r, _ := gc.VerifySubscription(&GooglePurchase{ + ProductType: "subscription", + ProductId: TEST_GOOGLE_PRODUCT_ID, + }) + + if r.Success || r.Message == nil { + t.Fatal("Google purchase should not have been successful.") + } + + if !r.PurchaseProviderReachable { + t.Fatal("Google purchase provider should have been reachable.") + } + + assertEquals(t, "Purchase is a subscription that expired.", r.Message.Error()) +} + +func TestGoogleProductValid(t *testing.T) { + gc := setupGoogleClient(&GoogleProductReceipt{ + PurchaseState: 0, + ConsumptionState: 0, + }) + + r, _ := gc.VerifyProduct(&GooglePurchase{ + ProductType: "product", + ProductId: TEST_GOOGLE_PRODUCT_ID, + }) + + if !r.PurchaseProviderReachable { + t.Fatal("Google purchase provider should have been reachable.") + } + + if !r.Success || r.Message != nil { + t.Fatal("Google purchase should have been successful.") + } +} + +func TestGoogleSubscriptionValid(t *testing.T) { + gc := setupGoogleClient(&GoogleSubscriptionReceipt{ + ExpiryTimeMillis: (time.Now().UnixNano() / 1000000) + 30, + }) + + r, _ := gc.VerifySubscription(&GooglePurchase{ + ProductType: "subscription", + ProductId: TEST_GOOGLE_PRODUCT_ID, + }) + + if !r.PurchaseProviderReachable { + t.Fatal("Google purchase provider should have been reachable.") + } + + if !r.Success || r.Message != nil { + t.Fatal("Google purchase should have been successful.") + } +} diff --git a/pkg/iap/model.go b/pkg/iap/model.go new file mode 100644 index 000000000..6ac87bb08 --- /dev/null +++ b/pkg/iap/model.go @@ -0,0 +1,109 @@ +// Copyright 2017 The Nakama Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package iap + +const ( + CONTENT_TYPE_APP_JSON = "application/json" +) + +type PurchaseVerifyResponse struct { + // Whether or not the transaction is valid and all the information matches. + Success bool + // If this is a new transaction or if Nakama has a log of it. + SeenBefore bool + // Indicates whether or not Nakama was able to reach the remote purchase service. + PurchaseProviderReachable bool + // A string indicating why the purchase verification failed, if appropriate. + Message error + // The complete response Nakama received from the remote service. + Data string +} + +type ApplePurchase struct { + // The receipt data returned by the purchase operation itself. + ProductId string + // The product, item, or subscription package ID the purchase relates to. + ReceiptData string +} + +type AppleRequest struct { + ReceiptData string `json:"receipt-data"` + Password string `json:"password"` +} + +type AppleResponse struct { + //Either 0 if the receipt is valid, or one of the error codes + Status int `json:"status"` + // A JSON representation of the receipt that was sent for verification + Receipt *AppleReceipt `json:"receipt"` + // Only returned for iOS 6 style transaction receipts for auto-renewable subscriptions. The base-64 encoded transaction receipt for the most recent renewal. + LatestReceipt string `json:"latest_receipt"` + // Only returned for iOS 6 style transaction receipts for auto-renewable subscriptions. The JSON representation of the receipt for the most recent renewal. + LatestReceiptInfo map[string]interface{} `json:"latest_receipt_info"` +} + +type AppleReceipt struct { + BundleID string `json:"bundle_id"` + ApplicationVersion string `json:"application_version"` + InApp []*AppleInAppReceipt `json:"in_app"` + OriginalApplicationVersion string `json:"original_application_version"` + CreationDate string `json:"creation_date"` + ExpirationDate string `json:"expiration_date"` +} + +type AppleInAppReceipt struct { + Quantity string `json:"quantity"` + ProductID string `json:"product_id"` + TransactionID string `json:"transaction_id"` + OriginalTransactionID string `json:"original_transaction_id"` + PurchaseDate string `json:"purchase_date"` + OriginalPurchaseDate string `json:"original_purchase_date"` + ExpiresDate string `json:"expires_date"` + AppItemID string `json:"app_item_id"` + VersionExternalIdentifier string `json:"version_external_identifier"` + WebOrderLineItemID string `json:"web_order_line_item_id"` + CancellationDate string `json:"cancellation_date"` +} + +type GooglePurchase struct { + // The identifier of the product or subscription being purchased. + ProductId string `json:"ProductId"` + // Whether the purchase is for a single product or a subscription. + ProductType string `json:"ProductType"` + // The token returned in the purchase operation response, acts as a transaction identifier. + PurchaseToken string `json:"PurchaseToken"` +} + +type GoogleProductReceipt struct { + Kind string `json:"kind"` + PurchaseTimeMillis int64 `json:"purchaseTimeMillis"` + PurchaseState int `json:"purchaseState"` + ConsumptionState int `json:"consumptionState"` + DeveloperPayload string `json:"developerPayload"` +} + +type GoogleSubscriptionReceipt struct { + Kind string `json:"kind"` + StartTimeMillis int64 `json:"startTimeMillis"` + ExpiryTimeMillis int64 `json:"expiryTimeMillis"` + AutoRenewing bool `json:"autoRenewing"` + PriceCurrencyCode string `json:"priceCurrencyCode"` + PriceAmountMicros int64 `json:"priceAmountMicros"` + CountryCode string `json:"countryCode"` + DeveloperPayload string `json:"developerPayload"` + PaymentState int `json:"paymentState"` + CancelReason int `json:"cancelReason"` + UserCancellationTimeMillis int64 `json:"userCancellationTimeMillis"` +} diff --git a/server/api.proto b/server/api.proto index 9ce33f99d..0e952933a 100644 --- a/server/api.proto +++ b/server/api.proto @@ -255,6 +255,9 @@ message Envelope { MatchmakeMatched matchmake_matched = 63; TRpc rpc = 64; + + TPurchaseValidation purchase = 65; + TPurchaseRecord purchase_record = 66; } } @@ -1237,3 +1240,55 @@ message TRpc { string id = 1; bytes payload = 2; } + +/** + * TPurchaseValidation is used to validation purchases made by the client. + * Verify an In-App Purchase receipt from Apple or Google purchases. + * + * @returns TPurchaseRecord + */ +message TPurchaseValidation { + /** + * Verify an In-App Purchase receipt from Apple purchases. + */ + message ApplePurchase { + // The product, item, or subscription package ID the purchase relates to. + string product_id = 1; + // The receipt data returned by the purchase operation itself. This must be converted to base64. + string receipt_data = 2; + } + + /** + * Verify an In-App Purchase receipt from Google purchases. + */ + message GooglePurchase { + // The identifier of the product or subscription being purchased. + string product_id = 1; + // Whether the purchase is for a single product or a subscription. + string product_type = 2; + // The token returned in the purchase operation response, acts as a transaction identifier. + string purchase_token = 3; + } + + oneof id { + ApplePurchase apple_purchase = 1; + GooglePurchase google_purchase = 2; + } +} + +/** + * TPurchaseRecord is the response of purchase validation + */ +message TPurchaseRecord { + // Whether or not the transaction is valid and all the information matches. + bool success = 1; + // If this is a new transaction or if Nakama has a log of it. + bool seen_before = 2; + // Indicates whether or not Nakama was able to reach the remote purchase service. + bool purchase_provider_reachable = 3; + // A string indicating why the purchase verification failed, if appropriate. + string message = 6; + // The complete response Nakama received from the remote service. + string data = 5; +} + diff --git a/server/config.go b/server/config.go index 5acf4af7d..addae66af 100644 --- a/server/config.go +++ b/server/config.go @@ -41,6 +41,7 @@ type Config interface { GetDatabase() *DatabaseConfig GetSocial() *SocialConfig GetRuntime() *RuntimeConfig + GetPurchase() *PurchaseConfig } func ParseArgs(logger *zap.Logger, args []string) Config { @@ -97,6 +98,7 @@ type config struct { Database *DatabaseConfig `yaml:"database" json:"database" usage:"Database connection settings"` Social *SocialConfig `yaml:"social" json:"social" usage:"Properties for social providers"` Runtime *RuntimeConfig `yaml:"runtime" json:"runtime" usage:"Script Runtime properties"` + Purchase *PurchaseConfig `yaml:"purchase" json:"purchase" usage:"In-App Purchase provider configuration"` } // NewConfig constructs a Config struct which represents server settings. @@ -115,6 +117,7 @@ func NewConfig() *config { Database: NewDatabaseConfig(), Social: NewSocialConfig(), Runtime: NewRuntimeConfig(), + Purchase: NewPurchaseConfig(), } } @@ -162,6 +165,10 @@ func (c *config) GetRuntime() *RuntimeConfig { return c.Runtime } +func (c *config) GetPurchase() *PurchaseConfig { + return c.Purchase +} + // LogConfig is configuration relevant to logging levels and output type LogConfig struct { // By default, log all messages with Warn and Error messages to a log file inside Data/Log/.log file. The content will be in JSON. @@ -267,3 +274,29 @@ func NewRuntimeConfig() *RuntimeConfig { HTTPKey: "defaultkey", } } + +// PurchaseConfig is configuration relevant to the In-App Purchase providers. +type PurchaseConfig struct { + Apple *ApplePurchaseProviderConfig `yaml:"apple" json:"apple" usage:"Apple In-App Purchase configuration"` + Google *GooglePurchaseProviderConfig `yaml:"google" json:"google" usage:"Google In-App Purchase configuration"` +} + +// NewPurchaseConfig creates a new PurchaseConfig struct +func NewPurchaseConfig() *PurchaseConfig { + return &PurchaseConfig{ + Apple: &ApplePurchaseProviderConfig{TimeoutMs: 1500}, + Google: &GooglePurchaseProviderConfig{TimeoutMs: 1500}, + } +} + +type ApplePurchaseProviderConfig struct { + Password string `yaml:"password" json:"password" usage:"In-App Purchase password"` + Production bool `yaml:"production" json:"production" usage:"If set, the server will try Production environment then sandbox."` + TimeoutMs int `yaml:"timeout_ms" json:"timeout_ms" usage:"Apple connection timeout in milliseconds"` +} + +type GooglePurchaseProviderConfig struct { + PackageName string `yaml:"package" json:"package" usage:"Android package name"` + ServiceKeyFilePath string `yaml:"service_key_file" json:"service_key_file" usage:"Absolute file path to the service key JSON file."` + TimeoutMs int `yaml:"timeout_ms" json:"timeout_ms" usage:"Google connection timeout in milliseconds"` +} diff --git a/server/core_purchase.go b/server/core_purchase.go new file mode 100644 index 000000000..41b5fe30a --- /dev/null +++ b/server/core_purchase.go @@ -0,0 +1,159 @@ +// Copyright 2017 The Nakama Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package server + +import ( + "database/sql" + "nakama/pkg/iap" + + "errors" + + "encoding/json" + + "github.com/satori/go.uuid" + "go.uber.org/zap" +) + +type PurchaseService struct { + logger *zap.Logger + db *sql.DB + AppleClient *iap.AppleClient + GoogleClient *iap.GoogleClient +} + +func NewPurchaseService(jsonLogger *zap.Logger, multiLogger *zap.Logger, db *sql.DB, config *PurchaseConfig) *PurchaseService { + ac, err := iap.NewAppleClient(config.Apple.Password, config.Apple.Production, config.Apple.TimeoutMs) + if err != nil { + multiLogger.Warn("Skip initialising Apple in-app purchase provider", zap.Error(err)) + } else { + if config.Apple.Production { + multiLogger.Info("Apple in-app purchase environment is set to Production priority.") + } else { + multiLogger.Info("Apple in-app purchase environment is set to Sandbox priority.") + } + multiLogger.Info("Successfully initiated Apple in-app purchase provider.") + } + + gc, err := iap.NewGoogleClient(config.Google.PackageName, config.Google.ServiceKeyFilePath, config.Google.TimeoutMs) + if err != nil { + multiLogger.Warn("Skip initialising Google in-app purchase provider", zap.Error(err)) + } + + return &PurchaseService{ + logger: jsonLogger, + db: db, + AppleClient: ac, + GoogleClient: gc, + } +} + +func (p *PurchaseService) ValidateApplePurchase(userID uuid.UUID, purchase *iap.ApplePurchase) *iap.PurchaseVerifyResponse { + r, appleReceipt := p.AppleClient.Verify(purchase) + if !r.Success { + return r + } + + //TODO: Improvement - Process more than one in-app receipts + inAppReceipt := appleReceipt.InApp[0] + p.checkUser(userID, r, 1, inAppReceipt.TransactionID) + + if r.Success && !r.SeenBefore { + err := p.savePurchase(userID.Bytes(), 1, inAppReceipt.ProductID, inAppReceipt.TransactionID, purchase.ReceiptData, r.Data) + if err != nil { + r.Success = false + r.Message = errors.New("Failed to validate purchase against ledger.") + jsonPurchase, _ := json.Marshal(purchase) + p.logger.Error("Could not save Apple purchase", zap.String("receipt", string(jsonPurchase)), zap.String("provider_resp", r.Data), zap.Error(err)) + } + } + return r +} + +func (p *PurchaseService) ValidateGooglePurchaseProduct(userID uuid.UUID, purchase *iap.GooglePurchase) *iap.PurchaseVerifyResponse { + r, _ := p.GoogleClient.VerifyProduct(purchase) + if !r.Success { + return r + } + + p.checkUser(userID, r, 0, purchase.PurchaseToken) + if r.Success && !r.SeenBefore { + jsonPurchase, _ := json.Marshal(purchase) + err := p.savePurchase(userID.Bytes(), 0, purchase.ProductId, purchase.PurchaseToken, string(jsonPurchase), r.Data) + if err != nil { + r.Success = false + r.Message = errors.New("Failed to validate purchase against ledger.") + p.logger.Error("Could not save Google product purchase", zap.String("receipt", string(jsonPurchase)), zap.String("provider_resp", r.Data), zap.Error(err)) + } + } + return r +} + +func (p *PurchaseService) ValidateGooglePurchaseSubscription(userID uuid.UUID, purchase *iap.GooglePurchase) *iap.PurchaseVerifyResponse { + r, _ := p.GoogleClient.VerifySubscription(purchase) + if !r.Success { + return r + } + + p.checkUser(userID, r, 0, purchase.PurchaseToken) + if r.Success && !r.SeenBefore { + jsonPurchase, _ := json.Marshal(purchase) + err := p.savePurchase(userID.Bytes(), 0, purchase.ProductId, purchase.PurchaseToken, string(jsonPurchase), r.Data) + if err != nil { + r.Success = false + r.Message = errors.New("Failed to validate purchase against ledger.") + p.logger.Error("Could not save Google subscription purchase", zap.String("receipt", string(jsonPurchase)), zap.String("provider_resp", r.Data), zap.Error(err)) + } + } + return r +} + +func (p *PurchaseService) checkUser(userID uuid.UUID, r *iap.PurchaseVerifyResponse, provider int, receiptID string) { + var purchaseUserID []byte + err := p.db.QueryRow("SELECT user_id FROM purchase WHERE provider = $1 AND receipt_id = $2", provider, receiptID).Scan(&purchaseUserID) + if err != nil { + if err != sql.ErrNoRows { + r.Success = false + r.Message = errors.New("Failed to validate purchase against ledger.") + p.logger.Error(r.Message.Error(), zap.Error(err)) + } + } + + // We've not seen this transaction + if len(purchaseUserID) == 0 { + r.Success = true + r.SeenBefore = false + r.Message = nil + } else { // We've seen this transaction + if uuid.Equal(userID, uuid.FromBytesOrNil(purchaseUserID)) { + r.Success = true + r.SeenBefore = true + r.Message = nil + } else { + r.Success = false + r.SeenBefore = true + r.Message = errors.New("Transaction already registered to a different user") + } + } +} + +func (p *PurchaseService) savePurchase(userID []byte, provider int, productID string, receiptID string, rawPurchase string, rawReceipt string) error { + createdAt := nowMs() + _, err := p.db.Exec(` +INSERT INTO purchase (user_id, provider, product_id, receipt_id, receipt, provider_resp, created_at) +VALUES ($1, $2, $3, $4, $5, $6, $7)`, + userID, provider, productID, receiptID, rawPurchase, rawReceipt, createdAt) + + return err +} diff --git a/server/pipeline.go b/server/pipeline.go index f7d7ef7c1..6c6024a9d 100644 --- a/server/pipeline.go +++ b/server/pipeline.go @@ -34,12 +34,21 @@ type pipeline struct { sessionRegistry *SessionRegistry socialClient *social.Client runtime *Runtime + purchaseService *PurchaseService jsonpbMarshaler *jsonpb.Marshaler jsonpbUnmarshaler *jsonpb.Unmarshaler } // NewPipeline creates a new Pipeline -func NewPipeline(config Config, db *sql.DB, tracker Tracker, matchmaker Matchmaker, messageRouter MessageRouter, registry *SessionRegistry, socialClient *social.Client, runtime *Runtime) *pipeline { +func NewPipeline(config Config, + db *sql.DB, + tracker Tracker, + matchmaker Matchmaker, + messageRouter MessageRouter, + registry *SessionRegistry, + socialClient *social.Client, + runtime *Runtime, + purchaseService *PurchaseService) *pipeline { return &pipeline{ config: config, db: db, @@ -50,6 +59,7 @@ func NewPipeline(config Config, db *sql.DB, tracker Tracker, matchmaker Matchmak sessionRegistry: registry, socialClient: socialClient, runtime: runtime, + purchaseService: purchaseService, jsonpbMarshaler: &jsonpb.Marshaler{ EnumsAsInts: true, EmitDefaults: false, diff --git a/server/pipeline_matchmake.go b/server/pipeline_matchmake.go index 1de3ff191..6ca8343f4 100644 --- a/server/pipeline_matchmake.go +++ b/server/pipeline_matchmake.go @@ -1,3 +1,17 @@ +// Copyright 2017 The Nakama Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + package server import ( diff --git a/server/pipeline_purchase.go b/server/pipeline_purchase.go new file mode 100644 index 000000000..5cc32e248 --- /dev/null +++ b/server/pipeline_purchase.go @@ -0,0 +1,108 @@ +// Copyright 2017 The Nakama Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package server + +import ( + "errors" + "nakama/pkg/iap" + + "strings" + + "go.uber.org/zap" +) + +func (p *pipeline) purchaseValidate(logger *zap.Logger, session *session, envelope *Envelope) { + purchase := envelope.GetPurchase() + + var validationResponse *iap.PurchaseVerifyResponse + + switch purchase.Id.(type) { + case *TPurchaseValidation_ApplePurchase_: + ap, err := p.convertApplePurchase(purchase.GetApplePurchase()) + if err != nil { + logger.Warn("Could not process purchases", zap.Error(err)) + session.Send(ErrorMessageBadInput(envelope.CollationId, err.Error())) + return + } + validationResponse = p.purchaseService.ValidateApplePurchase(session.userID, ap) + case *TPurchaseValidation_GooglePurchase_: + gp, err := p.convertGooglePurchase(purchase.GetGooglePurchase()) + if err != nil { + logger.Warn("Could not process purchases", zap.Error(err)) + session.Send(ErrorMessageBadInput(envelope.CollationId, err.Error())) + return + } + + switch gp.ProductType { + case "product": + validationResponse = p.purchaseService.ValidateGooglePurchaseProduct(session.userID, gp) + case "subscription": + validationResponse = p.purchaseService.ValidateGooglePurchaseSubscription(session.userID, gp) + } + } + + response := &Envelope_PurchaseRecord{PurchaseRecord: &TPurchaseRecord{ + Success: validationResponse.Success, + PurchaseProviderReachable: validationResponse.PurchaseProviderReachable, + SeenBefore: validationResponse.SeenBefore, + Message: validationResponse.Message.Error(), + Data: validationResponse.Data, + }} + + session.Send(&Envelope{CollationId: envelope.CollationId, Payload: response}) +} + +func (p *pipeline) convertApplePurchase(purchase *TPurchaseValidation_ApplePurchase) (*iap.ApplePurchase, error) { + if p.purchaseService.AppleClient == nil { + return nil, errors.New("Apple in-app purchase environment is not setup.") + } + + if purchase.ReceiptData == "" { + return nil, errors.New("Missing receipt data.") + } + + if purchase.ProductId == "" { + return nil, errors.New("Missing product ID.") + } + + return &iap.ApplePurchase{ + ProductId: purchase.ProductId, + ReceiptData: purchase.ReceiptData, + }, nil +} + +func (p *pipeline) convertGooglePurchase(purchase *TPurchaseValidation_GooglePurchase) (*iap.GooglePurchase, error) { + if p.purchaseService.AppleClient == nil { + return nil, errors.New("Apple in-app purchase environment is not setup.") + } + + if !(purchase.ProductType == "product" || purchase.ProductType == "subscription") { + return nil, errors.New("Product type is required and must be one of: product, subscription") + } + + if purchase.ProductId == "" { + return nil, errors.New("Missing product ID.") + } + + if purchase.PurchaseToken == "" { + return nil, errors.New("Missing purchase token.") + } + + return &iap.GooglePurchase{ + ProductType: strings.ToLower(purchase.ProductType), + ProductId: purchase.ProductId, + PurchaseToken: purchase.PurchaseToken, + }, nil +} diff --git a/tests/core_purchase_test.go b/tests/core_purchase_test.go new file mode 100644 index 000000000..b80587d33 --- /dev/null +++ b/tests/core_purchase_test.go @@ -0,0 +1,321 @@ +// Copyright 2017 The Nakama Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package tests + +import ( + "bytes" + "encoding/json" + "io/ioutil" + "nakama/pkg/iap" + "nakama/server" + "net/http" + + "testing" + + "github.com/satori/go.uuid" + "go.uber.org/zap" +) + +var ( + purchaseService *server.PurchaseService + purchaseUserID = uuid.NewV4() + purchaseBadUserID = uuid.NewV4() + purchaseProductID = "com.heroiclabs.iap" +) + +type roundTripperFunc func(*http.Request) (*http.Response, error) + +func (fn roundTripperFunc) RoundTrip(req *http.Request) (*http.Response, error) { + return fn(req) +} + +func setupAppleClient() *iap.AppleClient { + a, _ := json.Marshal(&iap.AppleResponse{ + Status: iap.APPLE_VALID, + Receipt: &iap.AppleReceipt{ + InApp: []*iap.AppleInAppReceipt{ + &iap.AppleInAppReceipt{ + ProductID: purchaseProductID, + }, + }, + }, + }) + + httpClient := &http.Client{Transport: roundTripperFunc(func(req *http.Request) (*http.Response, error) { + resp := &http.Response{ + StatusCode: 200, + Body: ioutil.NopCloser(bytes.NewReader(a)), + } + return resp, nil + })} + + ac, _ := iap.NewAppleClientWithHTTP("password", true, httpClient) + return ac +} + +func setupGoogleClient() *iap.GoogleClient { + g, _ := json.Marshal(&iap.GoogleProductReceipt{ + PurchaseState: 0, + ConsumptionState: 0, + }) + + httpClient := &http.Client{Transport: roundTripperFunc(func(req *http.Request) (*http.Response, error) { + resp := &http.Response{ + StatusCode: 200, + Body: ioutil.NopCloser(bytes.NewReader(g)), + } + return resp, nil + })} + + gc, _ := iap.NewGoogleClientWithHTTP("com.heroiclabs.iap.google.packagename", httpClient) + return gc +} + +func setupPurchaseService() (*server.PurchaseService, error) { + db, err := setupDB() + if err != nil { + return nil, err + } + + ac := setupAppleClient() + gc := setupGoogleClient() + + logger, _ := zap.NewDevelopment(zap.AddStacktrace(zap.ErrorLevel)) + ps := server.NewPurchaseService(logger, logger, db, server.NewPurchaseConfig()) + ps.AppleClient = ac + ps.GoogleClient = gc + return ps, nil +} + +func TestPurchases(t *testing.T) { + ps, err := setupPurchaseService() + if err != nil { + t.Fatal(err) + } + purchaseService = ps + + if v := t.Run("apple-valid-unseen-purchase", testAppleUnseenPurchase); !v { + t.Fatal("'apple-valid-unseen-purchase' test failed.") + } + if v := t.Run("apple-restore-purchase", testAppleRestorePurchase); !v { + t.Error("'apple-restore-purchase' test failed.") + } + if v := t.Run("apple-valid-purchase-wrong-user", testApplePurchaseWrongUser); !v { + t.Error("'apple-valid-purchase-wrong-user' test failed.") + } + + if v := t.Run("google-valid-unseen-purchase", testGoogleUnseenPurchase); !v { + t.Fatal("'google-valid-unseen-purchase' test failed.") + } + if v := t.Run("google-restore-purchase", testGoogleRestorePurchase); !v { + t.Error("'google-restore-purchase' test failed.") + } + if v := t.Run("google-valid-purchase-wrong-user", testGooglePurchaseWrongUser); !v { + t.Error("'google-valid-purchase-wrong-user' test failed.") + } + +} + +func testAppleUnseenPurchase(t *testing.T) { + r := purchaseService.ValidateApplePurchase(purchaseUserID, &iap.ApplePurchase{ + ProductId: purchaseProductID, + }) + + if r.Message != nil { + t.Error(r.Message) + t.FailNow() + } + + if !r.Success { + t.Error("Purchase was not successful") + t.FailNow() + } + + if !r.PurchaseProviderReachable { + t.Error("Purchase provider was not available") + t.FailNow() + } + + if r.SeenBefore { + t.Error("Purchase was seen before") + t.FailNow() + } + + if r.Data == "" { + t.Error("Purchase did not have provider data") + t.FailNow() + } +} + +func testAppleRestorePurchase(t *testing.T) { + r := purchaseService.ValidateApplePurchase(purchaseUserID, &iap.ApplePurchase{ + ProductId: purchaseProductID, + }) + + if !r.SeenBefore { + t.Error("Purchase was not seen before") + t.FailNow() + } + + if r.Message != nil { + t.Error(r.Message) + t.FailNow() + } + + if !r.Success { + t.Error("Purchase was not successful") + t.FailNow() + } + + if !r.PurchaseProviderReachable { + t.Error("Purchase provider was not available") + t.FailNow() + } + + if r.Data == "" { + t.Error("Purchase did not have provider data") + t.FailNow() + } +} + +func testApplePurchaseWrongUser(t *testing.T) { + r := purchaseService.ValidateApplePurchase(purchaseBadUserID, &iap.ApplePurchase{ + ProductId: purchaseProductID, + }) + + if r.Success { + t.Error("Purchase was successful") + t.FailNow() + } + + if !r.SeenBefore { + t.Error("Purchase was not seen before") + t.FailNow() + } + + if r.Message == nil { + t.Error("Error was empty") + t.FailNow() + } + + if !r.PurchaseProviderReachable { + t.Error("Purchase provider was not available") + t.FailNow() + } + + if r.Data == "" { + t.Error("Purchase did not have provider data") + t.FailNow() + } +} + +func testGoogleUnseenPurchase(t *testing.T) { + r := purchaseService.ValidateGooglePurchaseProduct(purchaseUserID, &iap.GooglePurchase{ + ProductId: purchaseProductID, + ProductType: "product", + PurchaseToken: "google-purchase-token", + }) + + if r.Message != nil { + t.Error(r.Message) + t.FailNow() + } + + if !r.Success { + t.Error("Purchase was not successful") + t.FailNow() + } + + if !r.PurchaseProviderReachable { + t.Error("Purchase provider was not available") + t.FailNow() + } + + if r.SeenBefore { + t.Error("Purchase was seen before") + t.FailNow() + } + + if r.Data == "" { + t.Error("Purchase did not have provider data") + t.FailNow() + } +} + +func testGoogleRestorePurchase(t *testing.T) { + r := purchaseService.ValidateGooglePurchaseProduct(purchaseUserID, &iap.GooglePurchase{ + ProductId: purchaseProductID, + ProductType: "product", + PurchaseToken: "google-purchase-token", + }) + + if !r.SeenBefore { + t.Error("Purchase was not seen before") + t.FailNow() + } + + if r.Message != nil { + t.Error(r.Message) + t.FailNow() + } + + if !r.Success { + t.Error("Purchase was not successful") + t.FailNow() + } + + if !r.PurchaseProviderReachable { + t.Error("Purchase provider was not available") + t.FailNow() + } + + if r.Data == "" { + t.Error("Purchase did not have provider data") + t.FailNow() + } +} + +func testGooglePurchaseWrongUser(t *testing.T) { + r := purchaseService.ValidateGooglePurchaseProduct(purchaseBadUserID, &iap.GooglePurchase{ + ProductId: purchaseProductID, + ProductType: "product", + PurchaseToken: "google-purchase-token", + }) + + if r.Success { + t.Error("Purchase was successful") + t.FailNow() + } + + if !r.SeenBefore { + t.Error("Purchase was not seen before") + t.FailNow() + } + + if r.Message == nil { + t.Error("Error was empty") + t.FailNow() + } + + if !r.PurchaseProviderReachable { + t.Error("Purchase provider was not available") + t.FailNow() + } + + if r.Data == "" { + t.Error("Purchase did not have provider data") + t.FailNow() + } +} -- GitLab