add standalone verification (bug 699486)

This commit is contained in:
Andy McKay 2011-11-15 10:40:46 -08:00
Родитель b98fac0afc
Коммит 0ecf41c484
12 изменённых файлов: 336 добавлений и 124 удалений

Просмотреть файл

@ -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')

26
docs/topics/services.rst Normal file
Просмотреть файл

@ -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))

154
services/verify.py Normal file
Просмотреть файл

@ -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]

18
services/wsgi/verify.wsgi Normal file
Просмотреть файл

@ -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.