Русский
Русский
English
Статистика
Реклама

Интеграция и серверная валидация инаппов для стора 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  класс из плагина 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 транзакции.

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

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

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

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

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

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

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

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

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

Источник: habr.com
К списку статей
Опубликовано: 25.05.2021 20:20:29
0

Сейчас читают

Комментариев (0)
Имя
Электронная почта

Блог компании lightmap

Python

Разработка мобильных приложений

Разработка игр

Unity

Валидация

In-app

Читеры

Геймдев

Онлайн-шутер

Инапп

Gamedev

Категории

Последние комментарии

  • Имя: Макс
    24.08.2022 | 11:28
    Я разраб в IT компании, работаю на арбитражную команду. Мы работаем с приламы и сайтами, при работе замечаются постоянные баны и лаги. Пацаны посоветовали сервис по анализу исходного кода,https://app Подробнее..
  • Имя: 9055410337
    20.08.2022 | 17:41
    поможем пишите в телеграм Подробнее..
  • Имя: sabbat
    17.08.2022 | 20:42
    Охренеть.. это просто шикарная статья, феноменально круто. Большое спасибо за разбор! Надеюсь как-нибудь с тобой связаться для обсуждений чего-либо) Подробнее..
  • Имя: Мария
    09.08.2022 | 14:44
    Добрый день. Если обладаете такой информацией, то подскажите, пожалуйста, где можно найти много-много материала по Yggdrasil и его уязвимостях для написания диплома? Благодарю. Подробнее..
© 2006-2024, personeltest.ru