[Python, Разработка мобильных приложений, Разработка игр, Unity] Интеграция и серверная валидация инаппов для стора Google Play — как защититься от читеров

Автор Сообщение
news_bot ®

Стаж: 6 лет 9 месяцев
Сообщений: 27286

Создавать темы news_bot ® написал(а)
25-Май-2021 22:30


Онлайн-проекты рано или поздно сталкиваются со взломом внутреннего стора, когда читеры накручивают себе игровые предметы, оружие или валюту. Классика. Наш 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 транзакции.Кроме того, в завершение начисления отправляется соответствующая статистика на сервер аналитики.На что еще обратить вниманиеВне зависимости от платформы, для которой реализуются встроенные покупки, важно проверить и обработать следующие ситуации:
  • При показе нативных окон магазина в процессе покупки игра может вылететь по памяти. Поэтому следует протестировать такой сценарий, чтобы удостовериться, что покупка после перезапуска корректно завершается и начисляется игроку.
  • На большинстве платформ в процессе взаимодействия с окнами платформенного магазина приложение уходит в бэкграунд, и при завершении покупки выводится из бэкграунда. За это время игра вполне может дисконнектнуться от серверов. Если для валидации или начисления покупки нужен коннект с сервером, то после возвращения в приложение нужно будет соединиться с ним вновь, и только потом производить валидацию или начисление.
  • Нужно тестировать сценарий, когда во время покупки и валидации игрок запускает новую покупку. Мы после тестирования этого сценария обнаружили баги и добавляли запрет запуска покупки, пока идет покупка другого инаппа.
Дополнительные ссылкиИ последнее: когда мы реализовывали валидацию инаппов для Google Play несколько лет назад, нам оказалось полезной статья на Хабре, вам она тоже может пригодиться. Также использовали решения, предложенные здесь и здесь. Ссылка на документацию по API серверной валидации Google — здесь.
===========
Источник:
habr.com
===========

Похожие новости: Теги для поиска: #_python, #_razrabotka_mobilnyh_prilozhenij (Разработка мобильных приложений), #_razrabotka_igr (Разработка игр), #_unity, #_razrabotka_igr (разработка игр), #_validatsija (валидация), #_inapp, #_chitery (читеры), #_gejmdev (геймдев), #_onlajnshuter (онлайн-шутер), #_inapp (инапп), #_gamedev, #_python, #_unity, #_blog_kompanii_lightmap (
Блог компании Lightmap
)
, #_python, #_razrabotka_mobilnyh_prilozhenij (
Разработка мобильных приложений
)
, #_razrabotka_igr (
Разработка игр
)
, #_unity
Профиль  ЛС 
Показать сообщения:     

Вы не можете начинать темы
Вы не можете отвечать на сообщения
Вы не можете редактировать свои сообщения
Вы не можете удалять свои сообщения
Вы не можете голосовать в опросах
Вы не можете прикреплять файлы к сообщениям
Вы не можете скачивать файлы

Текущее время: 22-Ноя 17:40
Часовой пояс: UTC + 5