Онлайн-проекты рано или поздно сталкиваются со взломом внутреннего стора, когда читеры накручивают себе игровые предметы, оружие или валюту. Классика. Наш PvP-шутер не стал исключением брешь мы в итоге закрыли, хотя и пришлось повозиться.
В этой статье расскажу про интеграцию и серверную валидацию инаппов с точки зрения клиента: какой плагин использовать для Google Play и на что обращать внимание независимо от платформы, а моя коллега поделится кодом серверной части.
Как уже говорилось в блоге, наш флагманский проект это мобильный PvP-шутер с DAU около 1 млн пользователей, большинство из которых на Android. В игре сотни видов оружия и предметов. И чтобы защититься от взлома, естественно, нужна валидация покупок. Пойдем по порядку.
В Google Play наш проект использует consumable in-apps, которые после успешной покупки и валидации начисляются игроку и сразу потребляются. По историческим причинам для Google Play мы используем плагин от Prime31.
Отмечу, что если бы мы сегодня добавляли встроенные покупки с нуля на эти платформы, то взяли бы Unity IAP (а, например, на Huawei публиковались бы через Unity Distribution Portal).
В игре много спецпредложений, но мы не стали заводить отдельный id инаппа под каждую акцию, вместо этого используем один набор айдишников инаппов для разных предметов и предложений. В момент нажатия на конкретную покупку мы запоминаем контент, который нужно выдать игроку при успешной покупке. При завершении покупки выдаем его.
Перейдем к коду покупки и валидации инаппов.
На старте приложения подписываемся на события покупки:
// GoogleIABManager класс из плагина Prime31GoogleIABManager.purchaseSucceededEvent += HandleGooglePurchaseSucceeded;
Когда игрок нажимает на инапп в интерфейсе запускаем покупку:
// GoogleIAB класс из плагина Prime31GoogleIAB.purchaseProduct(productId);
В обработчике успешного завершения покупки оборачиваем платформо-специфичную покупку в объект, реализующий IMarketPurchase. IMarketPurchase мы используем на всех платформах, чтобы сделать код валидации кроссплатформенным. В этот интерфейс мы оборачиваем классы из плагинов конкретных магазинов.
public interface IMarketPurchase{ string ProductId { get; } string OrderId { get; } string PurchaseToken { get; } object NativePurchase { get; }}class GoogleMarketPurchase : IMarketPurchase{ internal GoogleMarketPurchase(GooglePurchase purchase) { _purchase = purchase; } public string ProductId => _purchase.productId; public string OrderId => _purchase.orderId; public string PurchaseToken => _purchase.purchaseToken; public object NativePurchase => _purchase; private GooglePurchase _purchase;}internal static class MarketPurchaseFactory{// GooglePurchase класс из плагина Prime31 internal static IMarketPurchase CreateMarketPurchase(GooglePurchase purchase) { return new GoogleMarketPurchase(purchase); }}private void IapManagerOnBuyProductSuccess(PurchaseResultInfo purchaseResult){ var purchaseData = new InAppPurchaseData(purchaseResult.InAppPurchaseData); IMarketPurchase marketPurchase = MarketPurchaseFactory.CreateMarketPurchase(purchaseData); ValidatePurchase( marketPurchase );}
Отправляем покупку на наш сервер на валидацию:
private void ValidatePurchase(IMarketPurchase purchase){ var request = new InappValidationRequest { orderId = purchase.OrderId, productId = purchase.ProductId, purchaseToken = purchase.PurchaseToken, OnSuccess = () => ProvidePurchase(purchase), OnFail = () => Consume(purchase) }; WebSocketCallbacks.Subscribe(ServerEventNames.PurchasePrevalidate, PrevalidatePurchaseHandler); Dictionary<object, object> data = new Dictionary<object, object>(); data.Add("orderId", request.orderId); data.Add("productId", request.productId); data.Add("data", request.purchaseToken); int reqId = WebSocketManager.Instance.Send(ServerEventNames.PurchasePrevalidate, data); _valdationRequests.Add(reqId, request);}
Если валидация проходит неуспешно потребляем (Consume) продукт без начисления пользователю.
Если все хорошо потребляем продукт с начислением пользователю:
void ProvidePurchase(IMarketPurchase purchase){ GiveInGameCurrencyAndItems(purchase); Consume(purchase);}
Важный момент: метод Consume перед отправкой в магазин запроса на потребление запоминает, что мы уже начислили покупку игроку. Это нужно, если из-за проблем с сетью (или каких-то других) запрос на консьюм не дойдет до магазина. В таком случае, когда после перезапуска приложения нам придут незаконсьюмленные покупки, мы увидим, за какие из них уже начисляли игроку валюту и предметы.
Обработчик ответа с сервера:
private const int ERROR_CODE_SERVER_ERROR = 30;private const int ERROR_CODE_VALIDATION_ERROR = 31;private void PrevalidatePurchaseHandler(Dictionary<string, object> response){ int reqId = Convert.ToInt32(response["req_id"], CultureInfo.InvariantCulture); _valdationRequests.TryGetValue(reqId, out InappValidationRequest request); if (request == null) return; _valdationRequests.Remove(reqId); if (response["status"].Equals("ok")) { request.OnSuccess(); } else { int code = Convert.ToInt32(response["err_code"], CultureInfo.InvariantCulture); switch (code) { case ERROR_CODE_VALIDATION_ERROR: request.OnFail(); break; case ERROR_CODE_SERVER_ERROR: CoroutineRunner.DeferredAction(5f, () => TryValidateAgain()); break; default: // неизвестная ошибка, начисляем инапп (поступаем в пользу игрока) request.OnSuccess(null); break; } }}
В случае, если сервер вернул OK в статусе валидации, производим начисление и консьюм покупки. Если сервер вернул неизвестную ошибку, трактуем результат валидации в пользу игрока.
Для следующего раздела передаю слово нашему серверному программисту Ире Поповой.
Серверная валидация
Валидация на сервере состоит из двух этапов:
-
превалидация когда данные по платежу отправляются на сервер соответствующей платформы для проверки валидности;
-
начисление в случае успешно пройденной валидации купленных позиций.
Сначала сервер получает в качестве входных параметров данные, необходимые для проведения валидации. В Android это id позиции и токен. Методы валидации являются платформо-зависимыми. Но, как правило, включают в себя логику отправки данных на сервер валидации соответствующей платформы, обработку полученного результата и возврат соответствующего ответа на клиент. Дополнительно результат валидации записывается в redis для последующей быстрой проверки при начислении.
def validate_receipt(self, uid, data, platform): InAppSlot = PlayerProgress.first(f"player_id={uid} AND slot_id='35'") if not InAppSlot: raise RuntimeError(f"Fail get slot purchases: not found player:{uid} data:{data}") tid = data.get("tid") params = [] orders_data = [] valid_orders = [] if not tid or tid in InAppSlot.content: return False params = str(tid).split(self.IN_APP_ID_SEPARATOR) if platform == "ios": transaction_id = params[0] product_id = params[1] orders_data = self._get_receipt_ios(data.get("data"), data.get("test") == 1, transaction_id, product_id) error("[VALIDATION] {} {} {}".format(transaction_id, product_id, orders_data)) elif platform == "android": product_id = params[1] purchase_token = data.get("data") orders_data = self._get_receipt_android(product_id, purchase_token) elif platform == "amazon": receipt_sku = params[0] user_id = params[1] orders_data = self._get_receipt_amazon(user_id, receipt_sku) elif platform == "huawei": product_id = params[1] orders_data = self._get_receipt_huawei(product_id, tid, data.get("data", ""), data.get("account_flag", 0)) elif platform == "udp": product_id = params[1] orders_data = self._get_receipt_udp(product_id, params[0], data.get("data", "")) elif platform == "samsung": product_id = params[1] transaction_id = params[0] product_id = params[1] orders_data = self._get_receipt_samsung(data.get("data", ""), product_id) else: error("[InAppValidator] unknown platform") return False if not orders_data: error(f"[InAppValidator] fail get receipt {platform} player:{uid} data:{data}") return False key = f"inapp:{uid}:{tid}" for order in orders_data: if not order.is_success(): continue valid_orders.append(order) try: self.inapp_redis.setex(key, order.to_json(), 86400) except Exception as ex: exception(f"[InAppValidator] fail save inapp to redis: {ex}") if not valid_orders: warning(f"[InAppValidator] not valid receipt {orders_data[0].order_id}") return False return True
Пример получения данных с соответствующего сервера валидации для Android. Для обращения к серверу Google были использованы пакеты Google для Python apiclient и oauth2client.
def _get_receipt_android(self, product_id, token): if not self.android_authorized: self._android_auth() debug(f"[InAppValidator] android product_id: {product_id}, token: {token}") try: product = self.android_publisher.purchases().products().get( packageName=config.GOOGLE_SERVICE_ACCOUNT['package_name'], productId=product_id, token=token).execute() except client.AccessTokenRefreshError: self.android_authorized = False return self._get_receipt_android(product_id, token) except google_errors.HttpError as ex: if ex.resp.status == 401 or ex.resp.status == 503: self.android_authorized = False return self._get_receipt_android(product_id, token) return False if not product: warning("[InAppValidator] android product is NONE") return None order_id = product.get('orderId') if not order_id: warning(f"order_id is NONE: {product}") return None return [Receipt(order_id, product.get('purchaseState', -1), product_id)]class Receipt: def __init__(self, order_id, status, product_id, user_id=None, expire=0, trial=0, refund=0, latest_receipt=''): self.order_id = order_id self.status = status self.product_id = product_id self.user_id = user_id self.expire = expire if str(trial) == 'true': self.trial = 1 else: self.trial = 0 self.refund = refund self.latest_receipt = latest_receipt def is_success(self): return self.status == 0 def is_canceled(self): return self.status == 3 def is_valid(self): return self.order_id and self.product_id def to_dict(self): return {"id": self.order_id, "s": self.status, "p": self.product_id, "u": self.user_id, "e":self.expire, "t":self.trial,"r":self.refund,"lr":self.latest_receipt} def to_json(self): return json.dumps({"id": self.order_id, "s": self.status, "p": self.product_id, "u": self.user_id, "e":self.expire, "t":self.trial,"r":self.refund,"lr":self.latest_receipt})
Отдельной командой/набором команд происходит начисление купленных позиций. Одна позиция может содержать разнотипные итемы (например, деньги и оружие), и для каждого типа итема на сервере существует отдельная команда начисления.
Чтобы логически объединить несколько команд, привязанных к одному действию игрока, на клиенте и на сервере введено понятие снапшота специальной конструкции, представляющей собой объединение команд, в которой ни одна команда не выполнится, если хотя бы какая-то не пройдет проверку. Можно сказать, что это некий аналог транзакций в БД. В данном случае снапшот включает специальную команду валидации и команды начисления купленных позиций.
Команда валидации:
def validate_receipt(self, data): neededSlotsNames = [self.slotName] self.slots = self.get_slots_data(*neededSlotsNames) InAppSlot = self.slots.get(self.slotName, []) tid = data.get("tid") platform = data.get("pl") params = [] orders_data = [] valid_orders = [] if not tid: self.ThrowFail("not found required parameter") elif tid in InAppSlot: self.ThrowFail("already in slot") if not self.IsFail(): params = str(tid).split(self.IN_APP_ID_SEPARATOR) if not self.IsFail(): inapp_storage = InappStorage.get_instance() if inapp_storage.exists_transaction(self.platform, params[0]): self.ThrowFail("already_purchased {0} d".format(params[0]), VALIDATOR_RESULT_CODE.ALREADY_PURCHASED) self.FinalizeRequest({self.slotName: InAppSlot}, data) return # Try get from redis player_platform = self.platform if platform is not None and int(platform) == 4: player_platform = "udp" _prevalidate_order = self.inapp_redis.check_tid(self._player_id, tid) if _prevalidate_order: orders_data = Receipt.from_json(_prevalidate_order) elif player_platform == "ios": transaction_id = params[0] product_id = params[1] if not transaction_id or not product_id: self.ThrowFail(f"fail get receipt {self.platform}") else: orders_data = self._get_receipt_ios(data.get("data"), data.get("test") == 1, transaction_id, product_id) elif player_platform == "android": product_id = params[1] purchase_token = data.get("data") orders_data = self._get_receipt_android(product_id, purchase_token) elif player_platform == "amazon": receipt_sku = params[0] user_id = params[1] orders_data = self._get_receipt_amazon(user_id, receipt_sku) elif player_platform == "huawei": product_id = params[1] orders_data = self._get_receipt_huawei(product_id, tid, data.get("data", ""), data.get("account_flag", 0), data.get("subscribe")) elif platform == "udp": product_id = params[1] orders_data = self._get_receipt_udp(product_id, params[0], data.get("data", "")) elif platform == "samsung": product_id = params[1] transaction_id = params[0] product_id = params[1] orders_data = self._get_receipt_samsung(data.get("data", ""), product_id) else: self.ThrowFail("unknown platform") if not orders_data: self.ThrowFail(f"fail get receipt {player_platform} {self.platform}") if not self.IsFail(): for order in orders_data: if order.is_success(): valid_orders.append(order) if not valid_orders: self.ThrowFail("already_purchased {0}".format(orders_data[0].order_id), VALIDATOR_RESULT_CODE.ALREADY_PURCHASED) else: InAppSlot.append(tid) self.SetRequestSuccessful() if self._player_id in LOG_PLAYER_IDS: HashLog.error(f"[INAPP] id:{self._player_id} receipt:{data}") self.FinalizeRequest({self.slotName: InAppSlot}, data)
Команда валидации проверяет транзакцию если есть данные превалидации, то используются они. В противном случае, данные отправляются на сервер валидации для соответствующей платформы.
В случае успешного начисления, id транзакции сохраняется в соответствующий слот игрока запись в БД, которая хранит данные по платежным транзакциям данного игрока. Во избежание взлома платежки методом, когда одну валидную транзакцию используют для многократного начисления, в рамках валидации осуществляется проверка на существование данного id транзакции.
Кроме того, в завершение начисления отправляется соответствующая статистика на сервер аналитики.
На что еще обратить внимание
Вне зависимости от платформы, для которой реализуются встроенные покупки, важно проверить и обработать следующие ситуации:
-
При показе нативных окон магазина в процессе покупки игра может вылететь по памяти. Поэтому следует протестировать такой сценарий, чтобы удостовериться, что покупка после перезапуска корректно завершается и начисляется игроку.
-
На большинстве платформ в процессе взаимодействия с окнами платформенного магазина приложение уходит в бэкграунд, и при завершении покупки выводится из бэкграунда. За это время игра вполне может дисконнектнуться от серверов. Если для валидации или начисления покупки нужен коннект с сервером, то после возвращения в приложение нужно будет соединиться с ним вновь, и только потом производить валидацию или начисление.
-
Нужно тестировать сценарий, когда во время покупки и валидации игрок запускает новую покупку. Мы после тестирования этого сценария обнаружили баги и добавляли запрет запуска покупки, пока идет покупка другого инаппа.
Дополнительные ссылки
И последнее: когда мы реализовывали валидацию инаппов для Google Play несколько лет назад, нам оказалось полезной статья на Хабре, вам она тоже может пригодиться.
Также использовали решения, предложенные здесь и здесь. Ссылка на документацию по API серверной валидации Google здесь.