forked from mozilla/zamboni
-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathverify.py
349 lines (288 loc) · 11.2 KB
/
verify.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
import calendar
import json
from datetime import datetime
from time import gmtime, time
from urlparse import parse_qsl, urlparse
from wsgiref.handlers import format_date_time
from django.core.management import setup_environ
import jwt
from browserid.errors import ExpiredSignatureError
from django_statsd.clients import statsd
from lib.crypto.receipt import sign
from lib.cef_loggers import receipt_cef
from receipts import certs
from services.utils import settings
setup_environ(settings)
from utils import (CONTRIB_CHARGEBACK, CONTRIB_NO_CHARGE,
CONTRIB_PURCHASE, CONTRIB_REFUND,
log_configure, log_exception, log_info, mypool)
# Go configure the log.
log_configure()
# This has to be imported after the settings (utils).
import receipts # NOQA, used for patching in the tests
status_codes = {
200: '200 OK',
405: '405 Method Not Allowed',
500: '500 Internal Server Error',
}
class VerificationError(Exception):
pass
class InvalidReceipt(Exception):
"""
InvalidReceipt takes a message, which is then displayed back to the app so
they can understand the failure.
"""
pass
class RefundedReceipt(Exception):
pass
class Verify:
def __init__(self, receipt, environ):
self.receipt = receipt
self.environ = environ
# These will be extracted from the receipt.
self.decoded = None
self.addon_id = None
self.user_id = None
self.uuid = None
# This is so the unit tests can override the connection.
self.conn, self.cursor = None, None
def setup_db(self):
if not self.cursor:
self.conn = mypool.connect()
self.cursor = self.conn.cursor()
def check_full(self):
"""
This is the default that verify will use, this will
do the entire stack of checks.
"""
receipt_domain = urlparse(settings.WEBAPPS_RECEIPT_URL).netloc
try:
self.decoded = self.decode()
self.check_type('purchase-receipt')
self.check_db()
self.check_url(receipt_domain)
except InvalidReceipt, err:
return self.invalid(str(err))
try:
self.check_purchase()
except InvalidReceipt, err:
return self.invalid(str(err))
except RefundedReceipt:
return self.refund()
return self.ok_or_expired()
def check_without_purchase(self):
"""
This is what the developer and reviewer receipts do, we aren't
expecting a purchase, but require a specific type and install.
"""
try:
self.decoded = self.decode()
self.check_type('developer-receipt', 'reviewer-receipt')
self.check_db()
self.check_url(settings.DOMAIN)
except InvalidReceipt, err:
return self.invalid(str(err))
return self.ok_or_expired()
def check_without_db(self, status):
"""
This is what test receipts do, no purchase or install check.
In this case the return is custom to the caller.
"""
assert status in ['ok', 'expired', 'invalid', 'refunded']
try:
self.decoded = self.decode()
self.check_type('test-receipt')
self.check_url(settings.DOMAIN)
except InvalidReceipt, err:
return self.invalid(str(err))
return getattr(self, status)()
def decode(self):
"""
Verifies that the receipt can be decoded and that the initial
contents of the receipt are correct.
If its invalid, then just return invalid rather than give out any
information.
"""
try:
receipt = decode_receipt(self.receipt)
except:
log_exception({'receipt': '%s...' % self.receipt[:10],
'addon': self.addon_id})
log_info('Error decoding receipt')
raise InvalidReceipt('ERROR_DECODING')
try:
assert receipt['user']['type'] == 'directed-identifier'
except (AssertionError, KeyError):
log_info('No directed-identifier supplied')
raise InvalidReceipt('NO_DIRECTED_IDENTIFIER')
return receipt
def check_type(self, *types):
"""
Verifies that the type of receipt is what we expect.
"""
if self.decoded.get('typ', '') not in types:
log_info('Receipt type not in %s' % ','.join(types))
raise InvalidReceipt('WRONG_TYPE')
def check_url(self, domain):
"""
Verifies that the URL of the verification is what we expect.
:param domain: the domain you expect the receipt to be verified at,
note that "real" receipts are verified at a different domain
from the main marketplace domain.
"""
path = self.environ['PATH_INFO']
parsed = urlparse(self.decoded.get('verify', ''))
if parsed.netloc != domain:
log_info('Receipt had invalid domain')
raise InvalidReceipt('WRONG_DOMAIN')
if parsed.path != path:
log_info('Receipt had the wrong path')
raise InvalidReceipt('WRONG_PATH')
def check_db(self):
"""
Verifies the decoded receipt against the database.
Requires that decode is run first.
"""
if not self.decoded:
raise ValueError('decode not run')
self.setup_db()
# Get the addon and user information from the installed table.
try:
self.uuid = self.decoded['user']['value']
except KeyError:
# If somehow we got a valid receipt without a uuid
# that's a problem. Log here.
log_info('No user in receipt')
raise InvalidReceipt('NO_USER')
try:
storedata = self.decoded['product']['storedata']
self.addon_id = int(dict(parse_qsl(storedata)).get('id', ''))
except:
# There was some value for storedata but it was invalid.
log_info('Invalid store data')
raise InvalidReceipt('WRONG_STOREDATA')
def check_purchase(self):
"""
Verifies that the app has been purchased.
"""
sql = """SELECT id, type FROM addon_purchase
WHERE addon_id = %(addon_id)s
AND uuid = %(uuid)s LIMIT 1;"""
self.cursor.execute(sql, {'addon_id': self.addon_id,
'uuid': self.uuid})
result = self.cursor.fetchone()
if not result:
log_info('Invalid receipt, no purchase')
raise InvalidReceipt('NO_PURCHASE')
if result[-1] in (CONTRIB_REFUND, CONTRIB_CHARGEBACK):
log_info('Valid receipt, but refunded')
raise RefundedReceipt
elif result[-1] in (CONTRIB_PURCHASE, CONTRIB_NO_CHARGE):
log_info('Valid receipt')
return
else:
log_info('Valid receipt, but invalid contribution')
raise InvalidReceipt('WRONG_PURCHASE')
def invalid(self, reason=''):
receipt_cef.log(self.environ, self.addon_id, 'verify',
'Invalid receipt')
return {'status': 'invalid', 'reason': reason}
def ok_or_expired(self):
# This receipt is ok now let's check it's expiry.
# If it's expired, we'll have to return a new receipt
try:
expire = int(self.decoded.get('exp', 0))
except ValueError:
log_info('Error with expiry in the receipt')
return self.expired()
now = calendar.timegm(gmtime()) + 10 # For any clock skew.
if now > expire:
log_info('This receipt has expired: %s UTC < %s UTC'
% (datetime.utcfromtimestamp(expire),
datetime.utcfromtimestamp(now)))
return self.expired()
return self.ok()
def ok(self):
return {'status': 'ok'}
def refund(self):
receipt_cef.log(self.environ, self.addon_id, 'verify',
'Refunded receipt')
return {'status': 'refunded'}
def expired(self):
receipt_cef.log(self.environ, self.addon_id, 'verify',
'Expired receipt')
if settings.WEBAPPS_RECEIPT_EXPIRED_SEND:
self.decoded['exp'] = (calendar.timegm(gmtime()) +
settings.WEBAPPS_RECEIPT_EXPIRY_SECONDS)
# Log that we are signing a new receipt as well.
receipt_cef.log(self.environ, self.addon_id, 'sign',
'Expired signing request')
return {'status': 'expired',
'receipt': sign(self.decoded)}
return {'status': 'expired'}
def get_headers(length):
return [('Access-Control-Allow-Origin', '*'),
('Access-Control-Allow-Methods', 'POST'),
('Content-Type', 'application/json'),
('Content-Length', str(length)),
('Cache-Control', 'no-cache'),
('Last-Modified', format_date_time(time()))]
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'):
if settings.SIGNING_SERVER_ACTIVE:
verifier = certs.ReceiptVerifier(valid_issuers=
settings.SIGNING_VALID_ISSUERS)
try:
result = verifier.verify(receipt)
except ExpiredSignatureError:
# Until we can do something meaningful with this, just ignore.
return jwt.decode(receipt.split('~')[1], verify=False)
if not result:
raise VerificationError()
return jwt.decode(receipt.split('~')[1], verify=False)
else:
key = jwt.rsa_load(settings.WEBAPPS_RECEIPT_KEY)
raw = jwt.decode(receipt, key)
return raw
def status_check(environ):
output = ''
# Check we can read from the users_install table, should be nice and
# fast. Anything that fails here, connecting to db, accessing table
# will be an error we need to know about.
if not settings.SIGNING_SERVER_ACTIVE:
return 500, 'SIGNING_SERVER_ACTIVE is not set'
try:
conn = mypool.connect()
cursor = conn.cursor()
cursor.execute('SELECT id FROM users_install ORDER BY id DESC LIMIT 1')
except Exception, err:
return 500, str(err)
return 200, output
def receipt_check(environ):
output = ''
with statsd.timer('services.verify'):
data = environ['wsgi.input'].read()
try:
verify = Verify(data, environ)
return 200, json.dumps(verify.check_full())
except:
log_exception('<none>')
return 500, ''
return output
def application(environ, start_response):
body = ''
path = environ.get('PATH_INFO', '')
if path == '/services/status/':
status, body = status_check(environ)
else:
# Only allow POST through as per spec.
if environ.get('REQUEST_METHOD') != 'POST':
status = 405
else:
status, body = receipt_check(environ)
start_response(status_codes[status], get_headers(len(body)))
return [body]