Онлайн-проекты рано или поздно сталкиваются со взломом внутреннего стора, когда читеры накручивают себе игровые предметы, оружие или валюту. Классика. Наш 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 — здесь.
gudvinr
Правильно сказать, что сталкиваются с этим разработчики юнити-игр, которые не вспоминают об этом пока жареный петух не клюнет.
Проблема в том, что все онлайн-проекты, которые с такой проблемой сталкиваются, по какой-то магической причине разрабатывают клиент-серверное приложение в котором недоверенная сторона (клиент) имеет безграничный уровень доверия.
Почему это именно так? Возьмите для примера абстрактный клиент банка.
Если бы банковские клиенты разрабатывали по тем же принципам, по которым делают юнити-игры, то данные о счёте и все транзакции проводились бы прямо на клиенте, а на сервер отправлялась информация о балансе пользователя. Ну и аналитика, конечно же.
Любое приложение, в котором принимаются деньги и есть взаимодействие между пользователями, должно разрабатываться как клиент-серверное приложение, а не как толстый клиент с нашлёпкой в виде сервера, который валидирует что-нибудь.
Эта история, что характерно, абсолютно типичная для юнити-игр, и в таких проектах онлайн составляющая полностью вывернута наизнанку. Управляющим звеном является клиент, и к нему накидываются куча костылей в виде ассетов, бесплатных и не очень, в бесконечных попытках остановить читеров. По большому счёту, никакого "взлома" и не происходит, просто система изначально спроектирована так, что клиент обладает безграничными возможностями.
Большинство проблем, приводящих к такого рода "взломам", просто невозможны, когда клиент всего лишь сообщает о намерении что-либо сделать на сервер, а не изменяет своё состояние самостоятельно.
Валидацию можно подделать путём использования MitM прокси, либо банально пропатчить приложение, чтобы проверка всегда была положительной. Да, от этого можно защититься разными методами, но пока вы доверяете клиенту, всегда будет возможность эксплуатировать уязвимости если не напрямую, то косвенно.
freeExec
Прямо видно, как у вас подгорает именно от
юнити-игр
algotrader2013
И это логично. Главный риск для разраба не в том, что его игру зачитерят, а в том, что играть в нее будет только он, его родители и парочка самых преданных друзей.
Поэтому, логично, что лучше сделать 10 игр без защиты вообще, и, когда одна взлетит и ее начнут читерить, то тушить пожар, чем все это время делать одну, но по стандартам финансовых приложений, и потом остаться ни с чем, когда окажется, что она не взлетела
gudvinr
Это справедливо для однопользовательских игр, в этом случае разумнее может быть не предпринимать таких действий вообще.
До какой-то степени справедливо для однопользовательских игр с минимальным взаимодействием игроков: возможность поглядеть на внешность/прогресс другого игрока, рейтинги и т.д.
В этом случае можно внутренние изменения проводить невидимо для пользователя.
Но если вы делаете PvP игру и изначально не проектируете архитектуру определённым образом, то когда она взлетит, вы не сможете быстро и незаметно переписывать приложение, поэтому это приведёт сначала к попытке прикрыть отдельные дыры костылями, потому что так дешевле, а потом либо к переписыванию кода, который уже оброс костылями, если к этому времени проект не умер
А если все 10 игр — это PvP игры, то какой-то костяк для них можно написать один раз и переиспользовать
PetrRnd Автор
У банков другие бюджеты и прибыль, они не могут проверять прототипы своих приложений про деньги на живой аудитории в софт-лонче. Геймдев все-таки про другое. На прототипах проверяется жизнеспособность идей: он вообще может эволюционировать в другую игру или внезапно выстрелить, когда команда разработки к этому не особо готова.
Здесь крайне важный момент: очень много студий и разработчиков на начальных этапах существования не имеют ресурсов, бюджетов, времени и экспертизы, чтобы изначально реализовать правильную архитектуру и безопасность. Основная цель — сделать проект, который понравится игрокам, а потом начинать вкладываться, чтобы удерживать его в сторах.
Обычно у всех в запасе есть длиннющий список фичей для реализации: по геймплею, арту и так далее. Но приходится выбирать те, которые имеют первостепенную важность — опять же, чтобы выжить на конкурентном рынке и привлечь игроков.
Когда в 2013 мы создавали Pixel Gun 3D, у нас была похожая ситуация. Атмосфера стартапа: экспертиза на тот момент было ограничена, а ресурсов и времени хватало только на разработку геймплейных фич. О безопасности некогда было думать, да и проблема читеров остро не стояла. Когда проект вырос и начались взломы, то переделать всю архитектуру, не навредив пользовательскому опыту, оказалось той еще задачкой. Такие изменения на огромной активной аудитории могут привести к гораздо более худшим последствиям, чем читеры.