add standalone verification (bug 699486)
This commit is contained in:
Родитель
b98fac0afc
Коммит
0ecf41c484
|
@ -109,80 +109,6 @@ class TestPrice(amo.tests.TestCase):
|
|||
eq_(prices[0].get_price_locale(), u'$0.99')
|
||||
|
||||
|
||||
class ReceiptCase(amo.tests.TestCase):
|
||||
fixtures = ['base/addon_3615', 'base/users']
|
||||
|
||||
def setUp(self):
|
||||
self.addon = Addon.objects.get(pk=3615)
|
||||
self.addon.update(type=amo.ADDON_WEBAPP,
|
||||
manifest_url='http://cbc.ca/manifest')
|
||||
self.addon = Addon.objects.get(pk=3615)
|
||||
self.user = UserProfile.objects.get(pk=999)
|
||||
self.url = reverse('api.market.verify', args=[self.addon.slug])
|
||||
|
||||
|
||||
@mock.patch('addons.models.Addon.is_premium', lambda x: True)
|
||||
class TestAddonReceipt(ReceiptCase):
|
||||
|
||||
def test_anonymous(self):
|
||||
eq_(self.client.get(self.url).status_code, 302)
|
||||
|
||||
def test_wrong_type(self):
|
||||
self.client.login(username='regular@mozilla.com', password='password')
|
||||
self.addon.update(type=amo.ADDON_EXTENSION)
|
||||
res = self.client.get(self.url)
|
||||
eq_(res.status_code, 400)
|
||||
|
||||
def test_logged_in(self):
|
||||
self.addon.update(premium_type=amo.ADDON_PREMIUM)
|
||||
self.client.login(username='regular@mozilla.com', password='password')
|
||||
res = self.client.get(self.url)
|
||||
eq_(res.status_code, 200)
|
||||
eq_(json.loads(res.content)['status'], 'invalid')
|
||||
|
||||
def test_logged_in_ok(self):
|
||||
self.addon.update(premium_type=amo.ADDON_PREMIUM)
|
||||
self.client.login(username='regular@mozilla.com', password='password')
|
||||
self.addon.addonpurchase_set.create(user=self.user)
|
||||
res = self.client.get(self.url)
|
||||
eq_(res.status_code, 200)
|
||||
eq_(json.loads(res.content)['status'], 'ok')
|
||||
|
||||
def test_logged_in_other(self):
|
||||
self.addon.update(premium_type=amo.ADDON_PREMIUM)
|
||||
self.client.login(username='admin@mozilla.com', password='password')
|
||||
self.addon.addonpurchase_set.create(user=self.user)
|
||||
res = self.client.get(self.url)
|
||||
eq_(res.status_code, 200)
|
||||
eq_(json.loads(res.content)['status'], 'invalid')
|
||||
|
||||
def test_user_not_purchased(self):
|
||||
self.addon.update(premium_type=amo.ADDON_PREMIUM)
|
||||
eq_(list(self.user.purchase_ids()), [])
|
||||
|
||||
def test_user_purchased(self):
|
||||
self.addon.update(premium_type=amo.ADDON_PREMIUM)
|
||||
self.addon.addonpurchase_set.create(user=self.user)
|
||||
eq_(list(self.user.purchase_ids()), [3615L])
|
||||
|
||||
|
||||
@mock.patch('addons.models.Addon.is_premium', lambda x: False)
|
||||
class TestAddonReceiptFree(ReceiptCase):
|
||||
|
||||
def test_free_install(self):
|
||||
self.client.login(username='regular@mozilla.com', password='password')
|
||||
self.addon.get_or_create_install(self.user)
|
||||
res = self.client.get(self.url)
|
||||
eq_(res.status_code, 200)
|
||||
eq_(json.loads(res.content)['status'], 'ok')
|
||||
|
||||
def test_free_no_install(self):
|
||||
self.client.login(username='regular@mozilla.com', password='password')
|
||||
res = self.client.get(self.url)
|
||||
eq_(res.status_code, 200)
|
||||
eq_(json.loads(res.content)['status'], 'invalid')
|
||||
|
||||
|
||||
class TestContribution(amo.tests.TestCase):
|
||||
fixtures = ['base/addon_3615', 'base/users']
|
||||
|
||||
|
|
|
@ -1,10 +0,0 @@
|
|||
from django.conf.urls.defaults import patterns, url
|
||||
|
||||
from addons.urls import ADDON_ID
|
||||
from market import views
|
||||
|
||||
|
||||
urlpatterns = patterns('',
|
||||
url(r'^verify/%s$' % ADDON_ID, views.verify_receipt,
|
||||
name='api.market.verify'),
|
||||
)
|
|
@ -1,25 +0,0 @@
|
|||
from django import http
|
||||
|
||||
import amo
|
||||
from amo.decorators import json_view, login_required
|
||||
from addons.decorators import addon_view
|
||||
|
||||
from statsd import statsd
|
||||
|
||||
|
||||
@login_required
|
||||
@addon_view
|
||||
@json_view
|
||||
def verify_receipt(request, addon):
|
||||
"""Returns the status for that addon."""
|
||||
with statsd.timer('marketplace.verification'):
|
||||
#TODO(andym): not sure what to do about refunded yet.
|
||||
if addon.type != amo.ADDON_WEBAPP:
|
||||
return http.HttpResponse(status=400)
|
||||
# If wanted we can use the watermark hash, however it's assumed the
|
||||
# users will be logged into AMO.
|
||||
if addon.is_premium():
|
||||
exists = addon.has_purchased(request.amo_user)
|
||||
else:
|
||||
exists = addon.installed.filter(user=request.amo_user).exists()
|
||||
return {'status': 'ok' if exists else 'invalid'}
|
|
@ -138,7 +138,7 @@ class Installed(amo.models.ModelBase):
|
|||
# This is the email used by user at the time of installation.
|
||||
# It might be the real email, or a pseudonym, this is what will be going
|
||||
# into the receipt for verification later.
|
||||
email = models.CharField(max_length=255)
|
||||
email = models.CharField(max_length=255, db_index=True)
|
||||
# Because the addon could change between free and premium,
|
||||
# we need to store the state at time of install here.
|
||||
premium_type = models.PositiveIntegerField(
|
||||
|
@ -169,7 +169,7 @@ def add_email(sender, **kw):
|
|||
@memoize(prefix='create-receipt', time=60 * 10)
|
||||
def create_receipt(installed_pk):
|
||||
installed = Installed.objects.get(pk=installed_pk)
|
||||
verify = reverse('api.market.verify', args=[installed.addon.pk])
|
||||
verify = '%s%s' % (settings.WEBAPPS_RECEIPT_URL, installed.addon.pk)
|
||||
detail = reverse('users.purchases.receipt', args=[installed.addon.pk])
|
||||
receipt = dict(typ='purchase-receipt',
|
||||
product=installed.addon.origin,
|
||||
|
@ -188,9 +188,3 @@ def get_key():
|
|||
return jwt.rsa_load(settings.WEBAPPS_RECEIPT_KEY)
|
||||
|
||||
|
||||
def decode_receipt(receipt):
|
||||
"""
|
||||
Cracks the receipt using the private key. This will probably change
|
||||
to using the cert at some point, especially when we get the HSM.
|
||||
"""
|
||||
return jwt.decode(receipt, get_key())
|
||||
|
|
|
@ -13,7 +13,7 @@ from addons.models import Addon, BlacklistedSlug
|
|||
from devhub.tests.test_views import BaseWebAppTest
|
||||
from users.models import UserProfile
|
||||
from versions.models import Version
|
||||
from webapps.models import Installed, Webapp, get_key, decode_receipt
|
||||
from webapps.models import Installed, Webapp, get_key
|
||||
|
||||
|
||||
key = os.path.join(os.path.dirname(__file__), 'sample.key')
|
||||
|
@ -211,11 +211,6 @@ class TestReceipt(amo.tests.TestCase):
|
|||
self.create_install(self.user, self.webapp)
|
||||
assert self.webapp.get_receipt(self.user)
|
||||
|
||||
def test_crack_receipt(self):
|
||||
receipt = self.create_install(self.user, self.webapp).receipt
|
||||
result = decode_receipt(receipt)
|
||||
eq_(result['typ'], u'purchase-receipt')
|
||||
|
||||
def test_install_has_email(self):
|
||||
install = self.create_install(self.user, self.webapp)
|
||||
eq_(install.email, u'regular@mozilla.com')
|
||||
|
|
|
@ -0,0 +1,95 @@
|
|||
# -*- coding: utf8 -*-
|
||||
from django.db import connection
|
||||
|
||||
from nose.tools import eq_
|
||||
|
||||
import amo
|
||||
import amo.tests
|
||||
from addons.models import Addon
|
||||
from services import verify
|
||||
from webapps.models import Installed
|
||||
from market.models import AddonPurchase
|
||||
from users.models import UserProfile
|
||||
from stats.models import Contribution
|
||||
|
||||
import json
|
||||
import mock
|
||||
|
||||
|
||||
class TestVerify(amo.tests.TestCase):
|
||||
fixtures = ['base/addon_3615', 'base/users']
|
||||
|
||||
def setUp(self):
|
||||
self.addon = Addon.objects.get(pk=3615)
|
||||
self.user = UserProfile.objects.get(email='regular@mozilla.com')
|
||||
self.user_data = {'user': {'value': self.user.email}}
|
||||
|
||||
def get_decode(self, addon_id, receipt):
|
||||
# Ensure that the verify code is using the test database cursor.
|
||||
v = verify.Verify(addon_id, receipt)
|
||||
v.cursor = connection.cursor()
|
||||
return json.loads(v())
|
||||
|
||||
@mock.patch.object(verify, 'decode_receipt')
|
||||
def get(self, addon_id, receipt, decode_receipt):
|
||||
decode_receipt.return_value = receipt
|
||||
return self.get_decode(addon_id, '')
|
||||
|
||||
def make_install(self):
|
||||
return Installed.objects.create(addon=self.addon, user=self.user)
|
||||
|
||||
def make_purchase(self):
|
||||
return AddonPurchase.objects.create(addon=self.addon, user=self.user)
|
||||
|
||||
def make_contribution(self, type=amo.CONTRIB_PURCHASE):
|
||||
return Contribution.objects.create(addon=self.addon, user=self.user,
|
||||
type=type)
|
||||
|
||||
def test_invalid_receipt(self):
|
||||
eq_(self.get_decode(1, 'blah')['status'], 'invalid')
|
||||
|
||||
def test_no_user(self):
|
||||
eq_(self.get(1, {})['status'], 'invalid')
|
||||
|
||||
def test_no_addon(self):
|
||||
eq_(self.get(0, {'user': {'value': 'a@a.com'}})['status'], 'invalid')
|
||||
|
||||
def test_user_addon(self):
|
||||
self.make_install()
|
||||
res = self.get(3615, self.user_data)
|
||||
eq_(res['status'], 'ok')
|
||||
eq_(res['receipt'], self.user_data)
|
||||
|
||||
def test_premium_addon_not_purchased(self):
|
||||
self.addon.update(premium_type=amo.ADDON_PREMIUM)
|
||||
self.make_install()
|
||||
res = self.get(3615, self.user_data)
|
||||
eq_(res['status'], 'invalid')
|
||||
|
||||
def test_premium_addon_purchased(self):
|
||||
self.addon.update(premium_type=amo.ADDON_PREMIUM)
|
||||
self.make_install()
|
||||
self.make_purchase()
|
||||
res = self.get(3615, self.user_data)
|
||||
eq_(res['status'], 'ok')
|
||||
|
||||
def test_premium_addon_contribution(self):
|
||||
self.addon.update(premium_type=amo.ADDON_PREMIUM)
|
||||
self.make_install()
|
||||
# There's no purchase, but the last entry we have is a sale.
|
||||
self.make_contribution()
|
||||
res = self.get(3615, self.user_data)
|
||||
eq_(res['status'], 'ok')
|
||||
|
||||
def test_premium_addon_refund(self):
|
||||
self.addon.update(premium_type=amo.ADDON_PREMIUM)
|
||||
self.make_install()
|
||||
for type in [amo.CONTRIB_REFUND, amo.CONTRIB_CHARGEBACK]:
|
||||
self.make_contribution(type=type)
|
||||
res = self.get(3615, self.user_data)
|
||||
eq_(res['status'], 'refunded')
|
||||
|
||||
def test_crack_receipt(self):
|
||||
receipt = self.make_install().receipt
|
||||
result = verify.decode_receipt(receipt)
|
||||
eq_(result['typ'], u'purchase-receipt')
|
|
@ -0,0 +1,26 @@
|
|||
.. _services:
|
||||
|
||||
==========================
|
||||
Services
|
||||
==========================
|
||||
|
||||
Services contain a couple of scripts that are run as seperate wsgi instances on
|
||||
the services. Usually they are hosted on seperate domains. They are stand alone
|
||||
wsgi scripts. The goal is to avoid a whole pile of Django imports, middleware,
|
||||
sessions and so on that we really don't need.
|
||||
|
||||
To run the scripts you'll want a wsgi server, on prod this is Apache and
|
||||
mod_wsgi. Locally you can optionally use `gunicorn`_, for example::
|
||||
|
||||
pip install gunicorn
|
||||
|
||||
Then you can do::
|
||||
|
||||
cd services
|
||||
gunicorn -c wsgi/verify.wsgi -b 127.0.0.1:9000 --debug verify:application
|
||||
|
||||
To test::
|
||||
|
||||
curl -d "this is a bogus receipt" http://127.0.0.1:9000/verify/123
|
||||
|
||||
.. _`Gunicorn`: http://gunicorn.org/
|
|
@ -1,2 +1,3 @@
|
|||
ALTER TABLE users_install ADD COLUMN email varchar(255) NOT NULL;
|
||||
ALTER TABLE users_install ADD COLUMN premium_type integer UNSIGNED;
|
||||
CREATE INDEX `users_install_email` ON `users_install` (`email`);
|
||||
|
|
|
@ -2,15 +2,28 @@ from datetime import datetime, timedelta
|
|||
import settings_local as settings
|
||||
import posixpath
|
||||
import re
|
||||
import sys
|
||||
|
||||
import MySQLdb as mysql
|
||||
import sqlalchemy.pool as pool
|
||||
|
||||
from django.core.management import setup_environ
|
||||
import commonware.log
|
||||
|
||||
# Pyflakes will complain about these, but they are required for setup.
|
||||
import settings_local as settings
|
||||
setup_environ(settings)
|
||||
import log_settings
|
||||
|
||||
# Ugh. But this avoids any zamboni or django imports at all.
|
||||
# Perhaps we can import these without any problems and we can
|
||||
# remove all this.
|
||||
|
||||
from constants.applications import APPS_ALL
|
||||
from constants.platforms import PLATFORMS
|
||||
from constants.base import (STATUS_PUBLIC, STATUS_DISABLED, STATUS_BETA,
|
||||
STATUS_LITE, STATUS_LITE_AND_NOMINATED)
|
||||
from constants.base import (CONTRIB_CHARGEBACK, CONTRIB_PURCHASE,
|
||||
CONTRIB_REFUND)
|
||||
|
||||
APP_GUIDS = dict([(app.guid, app.id) for app in APPS_ALL.values()])
|
||||
PLATFORMS = dict([(plat.api_name, plat.id) for plat in PLATFORMS.values()])
|
||||
|
@ -59,3 +72,24 @@ def get_mirror(status, id, row):
|
|||
host = settings.LOCAL_MIRROR_URL
|
||||
|
||||
return posixpath.join(host, str(id), row['filename'])
|
||||
|
||||
|
||||
def getconn():
|
||||
db = settings.SERVICES_DATABASE
|
||||
return mysql.connect(host=db['HOST'], user=db['USER'],
|
||||
passwd=db['PASSWORD'], db=db['NAME'])
|
||||
|
||||
|
||||
mypool = pool.QueuePool(getconn, max_overflow=10, pool_size=5, recycle=300)
|
||||
|
||||
|
||||
error_log = commonware.log.getLogger('z.services')
|
||||
|
||||
|
||||
def log_exception(data):
|
||||
(typ, value, discard) = sys.exc_info()
|
||||
error_log.error(u'Type: %s, %s. Data: %s' % (typ, value, data))
|
||||
|
||||
|
||||
def log_info(data, msg):
|
||||
error_log.info(u'Msg: %s, Data: %s' % (msg, data))
|
||||
|
|
|
@ -0,0 +1,154 @@
|
|||
import json
|
||||
import re
|
||||
|
||||
from utils import (log_exception, log_info, mypool, settings,
|
||||
CONTRIB_CHARGEBACK, CONTRIB_PURCHASE, CONTRIB_REFUND)
|
||||
|
||||
import jwt
|
||||
# This has to be imported after the settings (utils).
|
||||
from statsd import statsd
|
||||
|
||||
|
||||
class Verify:
|
||||
|
||||
def __init__(self, addon_id, receipt):
|
||||
self.addon_id = addon_id
|
||||
self.receipt = receipt
|
||||
# This is so the unit tests can override the connection.
|
||||
self.conn, self.cursor = None, None
|
||||
|
||||
def __call__(self):
|
||||
if not self.cursor:
|
||||
self.conn = mypool.connect()
|
||||
self.cursor = self.conn.cursor()
|
||||
|
||||
# 1. Try and decode the receipt data.
|
||||
# If its invalid, then just return invalid rather than give out any
|
||||
# information.
|
||||
try:
|
||||
receipt = decode_receipt(self.receipt)
|
||||
except jwt.DecodeError:
|
||||
self.log('Error decoding receipt')
|
||||
return self.invalid()
|
||||
|
||||
# 2. Get the addon and user information from the
|
||||
# installed table.
|
||||
try:
|
||||
email = receipt['user']['value']
|
||||
except KeyError:
|
||||
# If somehow we got a valid receipt without an email,
|
||||
# that's a problem. Log here.
|
||||
self.log('No user in receipt')
|
||||
return self.invalid()
|
||||
|
||||
sql = """SELECT id, user_id, premium_type FROM users_install
|
||||
WHERE addon_id = %(addon_id)s
|
||||
AND email = %(email)s LIMIT 1;"""
|
||||
self.cursor.execute(sql, {'addon_id': self.addon_id,
|
||||
'email': email})
|
||||
result = self.cursor.fetchone()
|
||||
if not result:
|
||||
# We've got no record of this receipt being created.
|
||||
self.log('No entry in users_install for email: %s' % email)
|
||||
return self.invalid()
|
||||
|
||||
rid, user_id, premium = result
|
||||
|
||||
# 3. If it's a premium addon, then we need to get that the purchase
|
||||
# information.
|
||||
if not premium:
|
||||
self.log('Valid receipt, not premium')
|
||||
return self.ok(receipt)
|
||||
|
||||
else:
|
||||
sql = """SELECT id FROM addon_purchase
|
||||
WHERE addon_id = %(addon_id)s
|
||||
AND user_id = %(user_id)s LIMIT 1;"""
|
||||
self.cursor.execute(sql, {'addon_id': self.addon_id,
|
||||
'user_id': user_id})
|
||||
result = self.cursor.fetchone()
|
||||
if result:
|
||||
self.log('Valid receipt, premium')
|
||||
return self.ok(receipt)
|
||||
|
||||
# 4. We've got no record of this sale, has this been
|
||||
# refunded?
|
||||
sql = """SELECT type FROM stats_contributions
|
||||
WHERE addon_id = %(addon_id)s
|
||||
AND user_id = %(user_id)s
|
||||
ORDER BY created DESC LIMIT 1;"""
|
||||
self.cursor.execute(sql, {'addon_id': self.addon_id,
|
||||
'user_id': user_id})
|
||||
result = self.cursor.fetchone()
|
||||
if not result:
|
||||
# We've got no contributions for this addon. Ouch.
|
||||
self.log('Valid receipt, no contributions for premium')
|
||||
return self.invalid()
|
||||
|
||||
if result[0] in [CONTRIB_REFUND, CONTRIB_CHARGEBACK]:
|
||||
self.log('Valid receipt, refunded')
|
||||
return self.refund()
|
||||
|
||||
elif result[0] == CONTRIB_PURCHASE:
|
||||
self.log('Valid receipt, valid contribution')
|
||||
return self.ok()
|
||||
|
||||
else:
|
||||
self.log('Valid receipt, but invalid contribution')
|
||||
return self.invalid()
|
||||
|
||||
def get_headers(self, length):
|
||||
return [('Content-Type', 'application/json'),
|
||||
('Content-Length', str(length))]
|
||||
|
||||
def invalid(self):
|
||||
return json.dumps({'status': 'invalid'})
|
||||
|
||||
def log(self, msg):
|
||||
log_info({'receipt': '%s...' % self.receipt[:10],
|
||||
'addon': self.addon_id}, msg)
|
||||
|
||||
def ok(self, receipt):
|
||||
return json.dumps({'status': 'ok', 'receipt': receipt})
|
||||
|
||||
def refund(self):
|
||||
return json.dumps({'status': 'refunded'})
|
||||
|
||||
|
||||
def decode_receipt(receipt):
|
||||
"""
|
||||
Cracks the receipt using the private key. This will probably change
|
||||
to using the cert at some point, especially when we get the HSM.
|
||||
"""
|
||||
with statsd.timer('services.decode'):
|
||||
key = jwt.rsa_load(settings.WEBAPPS_RECEIPT_KEY)
|
||||
raw = jwt.decode(receipt, key)
|
||||
return raw
|
||||
|
||||
# For consistency with the rest of amo, we'll include addon id in the
|
||||
# URL and pull it out using this regex.
|
||||
id_re = re.compile('/verify/(?P<addon_id>\d+)$')
|
||||
|
||||
|
||||
def application(environ, start_response):
|
||||
status = '200 OK'
|
||||
with statsd.timer('services.verify'):
|
||||
|
||||
data = environ['wsgi.input'].read()
|
||||
try:
|
||||
addon_id = id_re.search(environ['PATH_INFO']).group('addon_id')
|
||||
except AttributeError:
|
||||
output = ''
|
||||
log_exception({'receipt': '%s...' % data[:10], 'addon': 'empty'})
|
||||
start_response('500 Internal Server Error', [])
|
||||
|
||||
try:
|
||||
verify = Verify(addon_id, data)
|
||||
output = verify()
|
||||
start_response(status, verify.get_headers(len(output)))
|
||||
except:
|
||||
output = ''
|
||||
log_exception({'receipt': '%s...' % data[:10], 'addon': addon_id})
|
||||
start_response('500 Internal Server Error', [])
|
||||
|
||||
return [output]
|
|
@ -0,0 +1,18 @@
|
|||
import os
|
||||
import site
|
||||
|
||||
wsgidir = os.path.dirname(__file__)
|
||||
for path in ['../', '../..',
|
||||
'../../vendor/src',
|
||||
'../../vendor/src/django',
|
||||
'../../vendor/src/nuggets',
|
||||
'../../vendor/src/commonware',
|
||||
'../../vendor/src/statsd',
|
||||
'../../vendor/src/tower',
|
||||
'../../vendor/src/pyjwt',
|
||||
'../../lib',
|
||||
'../../vendor/lib/python',
|
||||
'../../apps']:
|
||||
site.addsitedir(os.path.abspath(os.path.join(wsgidir, path)))
|
||||
|
||||
from verify import application
|
|
@ -1257,6 +1257,10 @@ LOGIN_RATELIMIT_ALL_USERS = '15/m'
|
|||
# If this is true all new webapps go into an approval queue. If it's false then
|
||||
# they go public immediately.
|
||||
WEBAPPS_RESTRICTED = True
|
||||
# The verification URL, the addon id will be appended to this. This will
|
||||
# have to be altered to the right domain for each server, eg:
|
||||
# https://receiptcheck.addons.mozilla.org/verify/
|
||||
WEBAPPS_RECEIPT_URL = '%s/verify/' % SITE_URL
|
||||
# The key we'll use to sign webapp receipts.
|
||||
WEBAPPS_RECEIPT_KEY = ''
|
||||
# If True, only allow one webapp per domain.
|
||||
|
|
Загрузка…
Ссылка в новой задаче