最后更新于 .

2020-4-25更新

google提供了更方便的服务账号(Service Account)的方案,具体方式如下:

1. 去google developer api后台(https://console.developers.google.com/)
    1. 创建 project
    2. 进入创建好的project,找到 Google Play Android Developer API,并启用
    3. 在project中,创建服务账号(service account)
    4. 在service account中创建密钥,并下载json格式的密钥文件


2. 去google play console(https://play.google.com/apps/publish/)
    1. 点击左下角的设置=>API权限,选择关联的项目
    2. 给服务账号授予财务角色的权限
    3. 等待24小时生效


注意:
    1. 一定要先关联项目,再创建内购商品,否则无法校验。
    如果之前已经创建了商品,只能重新创建。注意,用过的product_id即使删掉也无法再用。

python库:

pip install google-api-python-client

2015-4-3更新

1. 将原有分散的代码封装为通用库 ggpay,代码在: https://github.com/dantezhu/ggpay,也可以直接 pip install ggpay 进行安装,使用方法见 examples 的get_token。


最近在google play上线的应用内支付被人刷了,用户模拟发起了大量的支付请求,并且全部成功支付。搞得我最近茶饭不思。。今天总算是解决了,和大家分享一下。

我们客户端的支付实现步骤是:

1. app端调用google支付

2. 支付成功后,调用 自己服务器的发货接口,当然发货接口是做了签名校验的。

之所以在app端调用发货,是因为google貌似没有提供服务器端直接回调url的地方,所以才给了恶意用户模拟google返回的机会。

 

一开始我以为是我们自己的发货接口密钥被破解了,但是后来经过app上报,发现客户端是真实的走过了所有的google支付流程,即google的支付sdk真的返回了成功。

由于不清楚是因为google的密钥泄漏还是攻击者用别的方法实现,所以客户端这边已经没有办法确认是安全的了。

好在google是提供了查询订单的接口的: http://developer.android.com/google/play/billing/gp-purchase-status-api.html

实现的流程在文档中已经写的很清楚了,我这里就不赘述了。

判断的方法也很简单:

1. 判断是否购买成功

2. 判断返回 developerPayload 是否与传入的值一致。最好传入订单号,以防止重放攻击。

 

实现代码如下:

# -*- coding: utf-8 -*-

import requests
import datetime

from .vals import logger


class GooglePurchaseChecker(object):
    """
    google的支付查询
    """

    client_id = None
    client_secret = None
    refresh_token = None
    access_token = None
    access_token_create_time = None
    access_token_expire_time = None

    def __init__(self, client_id, client_secret, refresh_token):
        self.client_id = client_id
        self.client_secret = client_secret
        self.refresh_token = refresh_token

    def get_new_access_token(self):
        """
        通过refresh_token获取access token
        """

        base_url = 'https://accounts.google.com/o/oauth2/token'

        data = dict(
            grant_type='refresh_token',
            client_id=self.client_id,
            client_secret=self.client_secret,
            refresh_token=self.refresh_token,
            )

        try:
            rsp = requests.post(base_url, data=data)
            jdata = rsp.json()

            if 'access_token' in jdata:
                self.access_token = jdata['access_token']
                self.access_token_create_time = datetime.datetime.now()
                self.access_token_expire_time = self.access_token_create_time + datetime.timedelta(
                    seconds=jdata['expires_in'] * 2 / 3
                )
                return True
            else:
                logger.error('no access_token: %s', rsp)
                return False
        except:
            logger.error('fail', exc_info=True)
            return False

    def should_get_new_access_token(self):
        """
        判断是否要重新获取access_token
        """
        if not self.access_token:
            return True

        now = datetime.datetime.now()
        if now >= self.access_token_expire_time:
            return True

        return False

    def check_purchase(self, bill_id, package_name, product_id, purchase_token):
        """
        判断是否合法
        """
        logger.error('purchase check start.bill_id: %s', bill_id)

        if self.should_get_new_access_token():
            if not self.get_new_access_token():
                # 如果没有成功获取到access_token,也先认为成功吧
                logger.error('get_new_access_token fail. bill_id:%s', bill_id)
                return -1

        url_tpl = 'https://www.googleapis.com/androidpublisher/v1.1/applications/{packageName}/inapp/{productId}/purchases/{token}'

        url = url_tpl.format(
            packageName=package_name,
            productId=product_id,
            token=purchase_token,
            )

        rsp = requests.get(url, params=dict(
            access_token=self.access_token,
            ))

        jdata = rsp.json()

        if 'purchaseState' not in jdata:
            logger.error('purchase invalid.bill_id: %s jdata: %s', bill_id, jdata)
            return -2

        if jdata['purchaseState'] == 0 and jdata['developerPayload'] == 'DeveloperPayloadITEM%s' % bill_id:
            logger.error('purchase valid.bill_id: %s jdata: %s', bill_id, jdata)
            return 0

        logger.error('purchase invalid.bill_id: %s jdata: %s', bill_id, jdata)
        return -3

最后感慨一下,以前在腾讯的时候,安全问题有大帮人帮你一起查,所以根本感觉不到什么危险。现在只有自己了,所有的问题都要考虑到,而且一旦处理不好就可能是致命的。

Pingbacks

Pingbacks已关闭。

评论

  1. 李雪冰

    李雪冰 on #

    敢问博主现在在哪里上班,还是自己出来做?

    Reply

  2. 李振华

    李振华 on #

    vimer您好, 我们也遇到这种问题, 请问一下refresh_token 这个token是从哪里来的呢?是支付返回的purchaseToken吗?

    Reply

    1. phper08

      phper08 on #

      第一次获取access_token的时候得到,在后面获取access_token的时候不再返回refresh_token,所以要保存好,当然移除了应用授权之后再获取access_token也是可以得到refresh_token的,在https://security.google.com/settings/security/permissions 这里删除授权

      Reply

  3. 李振华

    李振华 on #

    可以跟您交流一下吗? 我们也是在服务器端做了校验, 但是发现有用户模拟了大量可以通过RAS校验的订单数据,所以现在也只能用googleplay查询订单的方式来做验证了。但我们用了service account的JWT方式,获取token的时候总是报400无效授权, 所以想用您这种方式试试

    Reply

    1. Dante

      Dante on #

      你好没问题哈,其实我代码都贴在文章里的,应该直接拿过去用就行啦

      Reply

  4. wangying

    wangying on #

    你好,我按照上面的方法调用https://www.googleapis.com/androidpublisher/v1.1/applications/{packageName}/inapp/{productId}/purchases/{token} 这个的时候总是报 { "error": { "errors": [ { "domain": "global", "reason": "invalid", "message": "Invalid Value" } ], "code": 400, "message": "Invalid Value" }} 这个错误,这个订单的purchase_token 是客户端成功后返回的,请问怎么解呢?谢谢

    Reply

  5. xiaoqiang

    xiaoqiang on #

    请问博主,初始化时的三个参数(client_id, client_secret, refresh_token)都传什么啊?没看明白这块,麻烦帮忙解答下吧,万分感谢!这是客户端收到的谷歌返回的两个对象IabResult result, Purchase purchasepublic class IabResult { int mResponse; String mMessage;}public class Purchase { String mOrderId; String mPackageName; String mSku; long mPurchaseTime; int mPurchaseState; String mDeveloperPayload; String mToken; String mOriginalJson; String mSignature;}

    Reply

    1. xiaoqiang

      xiaoqiang on #

      博主,我在google通过通过Create Client ID 得到了一组json数据,下面是这组数据所有的key['auth_uri', 'redirect_uris', 'client_email', 'client_id', 'token_uri', 'client_secret', 'auth_provider_x509_cert_url', 'javascript_origins', 'client_x509_cert_url']其中client_id, client_secret找到了,但是 refresh_token还是不清楚应该传什么啊

      Reply

      1. Dante

        Dante on #

        https://accounts.google.com/o/oauth2/token 通过这个接口兑换回来的

        Reply

        1. 裴星鑫

          裴星鑫 on #

          是不是要客户端发起来获得?

          Reply

      2. Dante

        Dante on #

        参见: https://developers.google.com/accounts/docs/OAuth2InstalledApp#formingtheurl, 里面有个 grant_type=authorization_code 的部分就是

        Reply

        1. xiaoqiang

          xiaoqiang on #

          请问博主,这个里面的code是怎么得到的啊,我的数据里面没有找到code=4/v6xr77ewYqhvHSyW6UJ1w7jKwAzu&client_id=8819981768.apps.googleusercontent.com&client_secret=your_client_secret&redirect_uri=https://oauth2-login-demo.appspot.com/code&grant_type=authorization_code

          Reply

  6. xiaoqiang

    xiaoqiang on #

    感谢博主,这个校验我搞清楚是什么原理了,现在在做的是一个手机游戏在google play的支付校验,现在需要对方提供client_id, client_secret和redirect_uri,但是对方说没有这些。。。能问下博主,这些参数应该在哪里可以找到么,拜谢了!

    Reply

  7. 肥皂

    肥皂 on #

    博主,获取refresh_token的时候需要用户登陆授权么~还是怎么样可以通过发消息就获得呢~求解~

    Reply

  8. 郭明

    郭明 on #

    refresh_token就是code吗?这个是客户端去获得还是服务器去获得?

    Reply

    1. Dante

      Dante on #

      用开发者帐号登录授权,就可以拿到对应的code和refreshcode

      Reply

      1. 郭明

        郭明 on #

        code每次得到的值都不一样啊然后再获得refreshcode时,总是返回{error,invialid_request}

        Reply

  9. 郭明

    郭明 on #

    code需要重定向得到,然后用code得到refresh_token。。。是这样吧但code怎么能在游戏逻辑里去跟googleplay请求呢?

    Reply

  10. 郭明

    郭明 on #

    The browser will be redirected to your redirect URI with a code parameter, which will look similar to 4/eWdxD7b-YSQ5CNNb-c2iI83KQx19.wp6198ti5Zc7dJ3UXOl0T3aRLxQmbwI.这个code明显是通过浏览器来获取的,您是怎么处理的能完整的说下吗?多谢~

    Reply

  11. 牵着的x右手

    牵着的x右手 on #

    google api 使用 https://accounts.google.com/o/oauth2/token 已经获取不到 refresh_token 了?就是说你写的那段代码没用了是吗?只能通过异步验证:每天手工登录生成access_token,然后批量检查昨日订单,有问题的用户进行封号处理?如何手工登录生成access_token?又如何批量检查昨日订单

    Reply

    1. Dante

      Dante on #

      ggpay中readme有写

      Reply

  12. lidashuang

    lidashuang on #

    refresh_token 不会过期吧

    Reply

发表评论