Онлайн-проекты рано или поздно сталкиваются со взломом внутреннего стора, когда читеры накручивают себе игровые предметы, оружие или валюту. Классика. Наш 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 — здесь.