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