This repository has been archived by the owner on Feb 8, 2018. It is now read-only.
-
Notifications
You must be signed in to change notification settings - Fork 308
/
email.py
374 lines (290 loc) · 14.4 KB
/
email.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
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
# -*- coding: utf-8 -*-
from __future__ import absolute_import, division, print_function, unicode_literals
import uuid
from datetime import timedelta
from aspen.utils import utcnow
from psycopg2 import IntegrityError
import gratipay
from gratipay.exceptions import EmailAlreadyVerified, EmailTaken, CannotRemovePrimaryEmail
from gratipay.exceptions import EmailNotVerified, TooManyEmailAddresses, EmailNotOnFile
from gratipay.security.crypto import constant_time_compare
from gratipay.utils import encode_for_querystring
EMAIL_HASH_TIMEOUT = timedelta(hours=24)
class VerificationResult(object):
def __init__(self, name):
self.name = name
def __repr__(self):
return "<VerificationResult: %r>" % self.name
__str__ = __repr__
#: Signal that verifying an email address failed.
VERIFICATION_FAILED = VerificationResult('Failed')
#: Signal that verifying an email address was redundant.
VERIFICATION_REDUNDANT = VerificationResult('Redundant')
#: Signal that an email address is already verified for a different :py:class:`Participant`.
VERIFICATION_STYMIED = VerificationResult('Stymied')
#: Signal that email verification succeeded.
VERIFICATION_SUCCEEDED = VerificationResult('Succeeded')
class Email(object):
"""Participants may associate email addresses with their account.
Email addresses are stored in an ``emails`` table in the database, which
holds the addresses themselves as well as info related to address
verification. While a participant may have multiple email addresses on
file, verified or not, only one will be the *primary* email address: the
one also recorded in ``participants.email_address``. It's a bug for the
primary address not to be verified, or for an address to be in
``participants.email_address`` but not also in ``emails``.
Having a verified email is a prerequisite for certain other features on
Gratipay, such as linking a PayPal account, or filing a national identity.
"""
def start_email_verification(self, email, *packages):
"""Add an email address for a participant.
This is called when adding a new email address, and when resending the
verification email for an unverified email address.
:param unicode email: the email address to add
:param gratipay.models.package.Package packages: packages to optionally
also verify ownership of
:returns: ``None``
:raises EmailAlreadyVerified: if the email is already verified for
this participant (unless they're claiming packages)
:raises EmailTaken: if the email is verified for a different participant
:raises EmailNotOnFile: if the email address is not on file for any of
the packages
:raises TooManyEmailAddresses: if the participant already has 10 emails
:raises Throttled: if the participant adds too many emails too quickly
"""
with self.db.get_cursor() as c:
self.validate_email_verification_request(c, email, *packages)
link = self.get_email_verification_link(c, email, *packages)
verified_emails = self.get_verified_email_addresses()
kwargs = dict( npackages=len(packages)
, package_name=packages[0].name if packages else ''
, new_email=email
, new_email_verified=email in verified_emails
, link=link
, include_unsubscribe=False
)
self.app.email_queue.put(self, 'verification', email=email, **kwargs)
if self.email_address and self.email_address != email:
self.app.email_queue.put( self
, 'verification-notice'
# Don't count this one against their sending quota.
# It's going to their own verified address, anyway.
, _user_initiated=False
, **kwargs
)
def validate_email_verification_request(self, c, email, *packages):
"""Given a cursor, email, and packages, return ``None`` or raise.
"""
if not all(email in p.emails for p in packages):
raise EmailNotOnFile()
owner_id = c.one("""
SELECT participant_id
FROM emails
WHERE address = %(email)s
AND verified IS true
""", dict(email=email))
if owner_id:
if owner_id != self.id:
raise EmailTaken()
elif packages:
pass # allow reverify if claiming packages
else:
raise EmailAlreadyVerified()
if len(self.get_emails()) > 9:
if owner_id and owner_id == self.id and packages:
pass # they're using an already-verified email to verify packages
else:
raise TooManyEmailAddresses()
def get_email_verification_link(self, c, email, *packages):
"""Get a link to complete an email verification workflow.
:param Cursor c: the cursor to use
:param unicode email: the email address to be verified
:param packages: :py:class:`~gratipay.models.package.Package` objects
for which a successful verification will also entail verification of
ownership of the package
:returns: a URL by which to complete the verification process
"""
self.app.add_event( c
, 'participant'
, dict(id=self.id, action='add', values=dict(email=email))
)
nonce = self.get_email_verification_nonce(c, email)
if packages:
self.start_package_claims(c, nonce, *packages)
link = "{base_url}/~{username}/emails/verify.html?email2={encoded_email}&nonce={nonce}"
return link.format( base_url=gratipay.base_url
, username=self.username_lower
, encoded_email=encode_for_querystring(email)
, nonce=nonce
)
def get_email_verification_nonce(self, c, email):
"""Given a cursor and email address, return a verification nonce.
"""
nonce = str(uuid.uuid4())
existing = c.one( 'SELECT * FROM emails WHERE address=%s AND participant_id=%s'
, (email, self.id)
) # can't use eafp here because of cursor error handling
# XXX I forget what eafp is. :(
if existing is None:
# Not in the table yet. This should throw an IntegrityError if the
# address is verified for a different participant.
c.run( "INSERT INTO emails (participant_id, address, nonce) VALUES (%s, %s, %s)"
, (self.id, email, nonce)
)
else:
# Already in the table. Restart verification. Henceforth, old links
# will fail.
if existing.nonce:
c.run('DELETE FROM claims WHERE nonce=%s', (existing.nonce,))
c.run("""
UPDATE emails
SET nonce=%s
, verification_start=now()
WHERE participant_id=%s
AND address=%s
""", (nonce, self.id, email))
return nonce
def set_primary_email(self, email, cursor=None):
"""Set the primary email address for the participant.
"""
if cursor:
self._set_primary_email(email, cursor)
else:
with self.db.get_cursor() as cursor:
self._set_primary_email(email, cursor)
self.set_attributes(email_address=email)
def _set_primary_email(self, email, cursor):
if not getattr(self.get_email(email, cursor), 'verified', False):
raise EmailNotVerified()
self.app.add_event( cursor
, 'participant'
, dict(id=self.id, action='set', values=dict(primary_email=email))
)
cursor.run("""
UPDATE participants
SET email_address=%(email)s
WHERE username=%(username)s
""", dict(email=email, username=self.username))
def finish_email_verification(self, email, nonce):
"""Given an email address and a nonce as strings, return a three-tuple:
- a ``VERIFICATION_*`` constant;
- a list of packages if ``VERIFICATION_SUCCEEDED`` (``None``
otherwise), and
- a boolean indicating whether the participant's PayPal address was
updated if applicable (``None`` if not).
"""
_fail = VERIFICATION_FAILED, None, None
if '' in (email.strip(), nonce.strip()):
return _fail
with self.db.get_cursor() as cursor:
# Load an email record. Check for an address match, but don't check
# the nonce at this point. We want to compare in constant time to
# avoid timing attacks, and we'll do that below.
record = self.get_email(email, cursor, and_lock=True)
if record is None:
# We don't have that email address on file. Maybe it used to be
# on file but was explicitly removed (they followed an old link
# after removing in the UI?), or maybe it was never on file in
# the first place (they munged the querystring?).
return _fail
if record.nonce is None:
# Nonces are nulled out only when updating to mark an email
# address as verified; we always set a nonce when inserting.
# Therefore, the main way to get a null nonce is to issue a
# link, follow it, and follow it again.
# All records with a null nonce should be verified, though not
# all verified records will have a null nonce. That is, it's
# possible to land here with an already-verified address, and
# this is in fact expected when verifying package ownership via
# an already-verified address.
assert record.verified
return VERIFICATION_REDUNDANT, None, None
# *Now* verify that the nonce given matches the one expected, along
# with the time window for verification.
if not constant_time_compare(record.nonce, nonce):
return _fail
if (utcnow() - record.verification_start) > EMAIL_HASH_TIMEOUT:
return _fail
# And now we can load any packages associated with the nonce, and
# save the address.
packages = self.get_packages_claiming(cursor, nonce)
paypal_updated = None
try:
if packages:
paypal_updated = False
self.finish_package_claims(cursor, nonce, *packages)
self.save_email_address(cursor, email)
has_no_paypal = not self.get_payout_routes(good_only=True)
if packages and has_no_paypal:
self.set_paypal_address(email, cursor)
paypal_updated = True
except IntegrityError:
return VERIFICATION_STYMIED, None, None
return VERIFICATION_SUCCEEDED, packages, paypal_updated
def save_email_address(self, cursor, address):
"""Given an email address, modify the database.
This is where we actually mark the email address as verified.
Additionally, we clear out any competing claims to the same address.
"""
cursor.run("""
UPDATE emails
SET verified=true, verification_end=now(), nonce=NULL
WHERE participant_id=%s
AND address=%s
AND verified IS NULL
""", (self.id, address))
cursor.run("""
DELETE
FROM emails
WHERE participant_id != %s
AND address=%s
""", (self.id, address))
if not self.email_address:
self.set_primary_email(address, cursor)
def get_email(self, address, cursor=None, and_lock=False):
"""Return a record for a single email address on file for this participant.
:param unicode address: the email address for which to get a record
:param Cursor cursor: a database cursor; if ``None``, we'll use ``self.db``
:param and_lock: if True, we will acquire a write-lock on the email record before returning
:returns: a database record (a named tuple)
"""
sql = 'SELECT * FROM emails WHERE participant_id=%s AND address=%s'
if and_lock:
sql += ' FOR UPDATE'
return (cursor or self.db).one(sql, (self.id, address))
def get_emails(self, cursor=None):
"""Return a list of all email addresses on file for this participant.
"""
return (cursor or self.db).all("""
SELECT *
FROM emails
WHERE participant_id=%s
ORDER BY id
""", (self.id,))
def get_verified_email_addresses(self, cursor=None):
"""Return a list of verified email addresses on file for this participant.
"""
return [email.address for email in self.get_emails(cursor) if email.verified]
def remove_email(self, address):
"""Remove the given email address from the participant's account.
Raises ``CannotRemovePrimaryEmail`` if the address is primary. It's a
noop if the email address is not on file.
"""
if address == self.email_address:
raise CannotRemovePrimaryEmail()
with self.db.get_cursor() as c:
self.app.add_event( c
, 'participant'
, dict(id=self.id, action='remove', values=dict(email=address))
)
c.run("DELETE FROM emails WHERE participant_id=%s AND address=%s",
(self.id, address))
def set_email_lang(self, accept_lang):
"""Given a language identifier, set it for the participant as their
preferred language in which to receive email.
"""
if not accept_lang:
return
self.db.run("UPDATE participants SET email_lang=%s WHERE id=%s",
(accept_lang, self.id))
self.set_attributes(email_lang=accept_lang)