Интеграция и серверная валидация инаппов для стора Google Play — как защититься от читеров

от автора

Онлайн-проекты рано или поздно сталкиваются со взломом внутреннего стора, когда читеры накручивают себе игровые предметы, оружие или валюту. Классика. Наш PvP-шутер не стал исключением — брешь мы в итоге закрыли, хотя и пришлось повозиться.

В этой статье расскажу про интеграцию и серверную валидацию инаппов с точки зрения клиента: какой плагин использовать для Google Play и на что обращать внимание независимо от платформы, а моя коллега поделится кодом серверной части.

Как уже говорилось в блоге, наш флагманский проект — это мобильный PvP-шутер с DAU около 1 млн пользователей, большинство из которых на Android. В игре сотни видов оружия и предметов. И чтобы защититься от взлома, естественно, нужна валидация покупок. Пойдем по порядку.

В Google Play наш проект использует consumable in-apps, которые после успешной покупки и валидации начисляются игроку и сразу потребляются. По историческим причинам для Google Play мы используем плагин от Prime31.

Отмечу, что если бы мы сегодня добавляли встроенные покупки с нуля на эти платформы, то взяли бы Unity IAP (а, например, на Huawei публиковались бы через Unity Distribution Portal).

В игре много спецпредложений, но мы не стали заводить отдельный id инаппа под каждую акцию, вместо этого используем один набор айдишников инаппов для разных предметов и предложений. В момент нажатия на конкретную покупку мы запоминаем контент, который нужно выдать игроку при успешной покупке. При завершении покупки — выдаем его.

Перейдем к коду покупки и валидации инаппов.

На старте приложения подписываемся на события покупки:

// GoogleIABManager — класс из плагина Prime31 GoogleIABManager.purchaseSucceededEvent += HandleGooglePurchaseSucceeded;

Когда игрок нажимает на инапп в интерфейсе — запускаем покупку:

// GoogleIAB — класс из плагина Prime31 GoogleIAB.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 транзакции.

Кроме того, в завершение начисления отправляется соответствующая статистика на сервер аналитики.

На что еще обратить внимание

Вне зависимости от платформы, для которой реализуются встроенные покупки, важно проверить и обработать следующие ситуации:

  1. При показе нативных окон магазина в процессе покупки игра может вылететь по памяти. Поэтому следует протестировать такой сценарий, чтобы удостовериться, что покупка после перезапуска корректно завершается и начисляется игроку.

  2. На большинстве платформ в процессе взаимодействия с окнами платформенного магазина приложение уходит в бэкграунд, и при завершении покупки выводится из бэкграунда. За это время игра вполне может дисконнектнуться от серверов. Если для валидации или начисления покупки нужен коннект с сервером, то после возвращения в приложение нужно будет соединиться с ним вновь, и только потом производить валидацию или начисление.

  3. Нужно тестировать сценарий, когда во время покупки и валидации игрок запускает новую покупку. Мы после тестирования этого сценария обнаружили баги и добавляли запрет запуска покупки, пока идет покупка другого инаппа.

Дополнительные ссылки

И последнее: когда мы реализовывали валидацию инаппов для Google Play несколько лет назад, нам оказалось полезной статья на Хабре, вам она тоже может пригодиться. 

Также использовали решения, предложенные здесь и здесь. Ссылка на документацию по API серверной валидации Google — здесь.

ссылка на оригинал статьи https://habr.com/ru/company/lightmap/blog/559020/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *