From aebe84b694b69de4958965c84d5e1d04edbfa1f3 Mon Sep 17 00:00:00 2001 From: Rohit Paul Kuruvilla Date: Tue, 9 Sep 2014 00:55:13 +0530 Subject: [PATCH 001/107] Add hash and ctime fields to email_address_with_confirmation --- branch.sql | 5 +++++ gratipay/models/participant.py | 5 +++-- tests/py/test_close.py | 3 ++- 3 files changed, 10 insertions(+), 3 deletions(-) create mode 100644 branch.sql diff --git a/branch.sql b/branch.sql new file mode 100644 index 0000000000..5b8eb84056 --- /dev/null +++ b/branch.sql @@ -0,0 +1,5 @@ +BEGIN; + ALTER TYPE email_address_with_confirmation ADD ATTRIBUTE hash text; + ALTER TYPE email_address_with_confirmation ADD ATTRIBUTE ctime timestamp with time zone; +END; + diff --git a/gratipay/models/participant.py b/gratipay/models/participant.py index 6cd4aba32e..dcede6eadf 100644 --- a/gratipay/models/participant.py +++ b/gratipay/models/participant.py @@ -547,10 +547,11 @@ def update_avatar(self): self.set_attributes(avatar_url=avatar_url) def update_email(self, email, confirmed=False): + hash_string = str(uuid.uuid4()) with self.db.get_cursor() as c: add_event(c, 'participant', dict(id=self.id, action='set', values=dict(current_email=email))) - r = c.one("UPDATE participants SET email = ROW(%s, %s) WHERE username=%s RETURNING email" - , (email, confirmed, self.username) + r = c.one("UPDATE participants SET email = ROW(%s, %s, %s, %s) WHERE username=%s RETURNING email" + , (email, confirmed, hash_string, utcnow(),self.username) ) self.set_attributes(email=r) diff --git a/tests/py/test_close.py b/tests/py/test_close.py index c49fd7d77f..5f0ec321eb 100644 --- a/tests/py/test_close.py +++ b/tests/py/test_close.py @@ -10,6 +10,7 @@ from gratipay.models.community import Community from gratipay.models.participant import Participant from gratipay.testing import Harness +from aspen.utils import utcnow class TestClosing(Harness): @@ -282,7 +283,7 @@ def test_cpi_clears_personal_information(self): , anonymous_receiving=True , number='plural' , avatar_url='img-url' - , email=('alice@example.com', True) + , email=('alice@example.com', True, 'samplehash', utcnow()) , claimed_time='now' , session_token='deadbeef' , session_expires='2000-01-01' From 3c589fa7656831f1ffbd4a1e63789b6d9c43a1b0 Mon Sep 17 00:00:00 2001 From: Rohit Paul Kuruvilla Date: Tue, 9 Sep 2014 12:25:19 +0530 Subject: [PATCH 002/107] Update hash and ctime when email is changed --- gratipay/models/participant.py | 11 +++++++++-- tests/py/test_participant.py | 8 +++++++- www/%username/email.json.spt | 2 +- 3 files changed, 17 insertions(+), 4 deletions(-) diff --git a/gratipay/models/participant.py b/gratipay/models/participant.py index dcede6eadf..6f1373b525 100644 --- a/gratipay/models/participant.py +++ b/gratipay/models/participant.py @@ -547,11 +547,18 @@ def update_avatar(self): self.set_attributes(avatar_url=avatar_url) def update_email(self, email, confirmed=False): - hash_string = str(uuid.uuid4()) + hash_string = self.email.hash if hasattr(self.email,'hash') else '' + current_email = self.email.address if hasattr(self.email,'address') else '' + ctime = self.email.ctime if hasattr(self.email,'ctime') else utcnow() + if email != current_email: + confirmed = False + hash_string = str(uuid.uuid4()) + ctime = utcnow() + # Send the user an email here with self.db.get_cursor() as c: add_event(c, 'participant', dict(id=self.id, action='set', values=dict(current_email=email))) r = c.one("UPDATE participants SET email = ROW(%s, %s, %s, %s) WHERE username=%s RETURNING email" - , (email, confirmed, hash_string, utcnow(),self.username) + , (email, confirmed, hash_string, ctime,self.username) ) self.set_attributes(email=r) diff --git a/tests/py/test_participant.py b/tests/py/test_participant.py index ee5c1cc449..e1ee54651c 100644 --- a/tests/py/test_participant.py +++ b/tests/py/test_participant.py @@ -227,7 +227,13 @@ def test_can_change_email(self): actual = self.alice.email.address assert actual == expected - def test_can_confirm_email(self): + def test_cannot_confirm_email_in_one_step(self): + self.alice.update_email('alice@gratipay.com', True) + actual = self.alice.email.confirmed + assert actual == False + + def test_can_confirm_email_in_second_step(self): + self.alice.update_email('alice@gratipay.com') self.alice.update_email('alice@gratipay.com', True) actual = self.alice.email.confirmed assert actual == True diff --git a/www/%username/email.json.spt b/www/%username/email.json.spt index 612427069e..65b6c98774 100644 --- a/www/%username/email.json.spt +++ b/www/%username/email.json.spt @@ -24,4 +24,4 @@ else: user.participant.update_email(address) [---] application/json via json_dump -{'email': address} +{'email': address, 'confirmed': user.participant.email.confirmed } From c12554c9fbfb124fdff7248f5980c6c2e6dbe726 Mon Sep 17 00:00:00 2001 From: Rohit Paul Kuruvilla Date: Tue, 9 Sep 2014 15:10:40 +0530 Subject: [PATCH 003/107] Verification page --- gratipay/models/participant.py | 6 ++- tests/py/test_email_json.py | 2 +- tests/py/test_verify_email_html.py | 79 +++++++++++++++++++++++++++++ www/%username/verify-email.html.spt | 53 +++++++++++++++++++ 4 files changed, 137 insertions(+), 3 deletions(-) create mode 100644 tests/py/test_verify_email_html.py create mode 100644 www/%username/verify-email.html.spt diff --git a/gratipay/models/participant.py b/gratipay/models/participant.py index 6f1373b525..f0cc7e441a 100644 --- a/gratipay/models/participant.py +++ b/gratipay/models/participant.py @@ -550,9 +550,10 @@ def update_email(self, email, confirmed=False): hash_string = self.email.hash if hasattr(self.email,'hash') else '' current_email = self.email.address if hasattr(self.email,'address') else '' ctime = self.email.ctime if hasattr(self.email,'ctime') else utcnow() - if email != current_email: + was_confirmed = self.email.confirmed if hasattr(self.email,'confirmed') else '' + if (email != current_email) or (email == current_email and confirmed == was_confirmed == False): confirmed = False - hash_string = str(uuid.uuid4()) + hash_string = str(uuid.uuid4()) ctime = utcnow() # Send the user an email here with self.db.get_cursor() as c: @@ -561,6 +562,7 @@ def update_email(self, email, confirmed=False): , (email, confirmed, hash_string, ctime,self.username) ) self.set_attributes(email=r) + return r def update_goal(self, goal): typecheck(goal, (Decimal, None)) diff --git a/tests/py/test_email_json.py b/tests/py/test_email_json.py index 7a3fd9c315..3f63566c78 100644 --- a/tests/py/test_email_json.py +++ b/tests/py/test_email_json.py @@ -4,7 +4,7 @@ from gratipay.testing import Harness -class TestMembernameJson(Harness): +class TestEmailJson(Harness): def change_email_address(self, address, user='alice', should_fail=True): self.make_participant("alice") diff --git a/tests/py/test_verify_email_html.py b/tests/py/test_verify_email_html.py new file mode 100644 index 0000000000..77199b5749 --- /dev/null +++ b/tests/py/test_verify_email_html.py @@ -0,0 +1,79 @@ +from gratipay.models.participant import Participant +from gratipay.testing import Harness + + +class TestForVerifyEmail(Harness): + + def change_email_address(self, address, username, should_fail=False): + url = "/%s/email.json" % username + if should_fail: + response = self.client.PxST(url + , {'email': address,} + , auth_as=username + ) + else: + response = self.client.POST(url + , {'email': address,} + , auth_as=username + ) + return response + + def verify_email(self, username, hash_string, should_fail=False): + url = '/%s/verify-email.html?hash=%s' % (username , hash_string) + if should_fail: + response = self.client.GxT(url) + else: + response = self.client.GET(url) + return response + + def test_verify_email_without_adding_email(self): + participant = self.make_participant('alice') + response = self.verify_email(participant.username,'sample-hash', should_fail=True) + assert response.code == 404 + + def test_verify_email_wrong_hash(self): + participant = self.make_participant('alice', claimed_time="now") + self.change_email_address('alice@gmail.com', participant.username) + self.verify_email(participant.username,'sample-hash') + expected = False + actual = Participant.from_username(participant.username).email.confirmed + assert expected == actual + + def test_verify_email(self): + participant = self.make_participant('alice', claimed_time="now") + self.change_email_address('alice@gmail.com', participant.username) + hash_string = Participant.from_username(participant.username).email.hash + self.verify_email(participant.username,hash_string) + expected = True + actual = Participant.from_username(participant.username).email.confirmed + assert expected == actual + + def test_email_is_not_confirmed_after_update(self): + participant = self.make_participant('alice', claimed_time="now") + self.change_email_address('alice@gmail.com', participant.username) + hash_string = Participant.from_username(participant.username).email.hash + self.verify_email(participant.username,hash_string) + self.change_email_address('alice@yahoo.com', participant.username) + expected = False + actual = Participant.from_username(participant.username).email.confirmed + assert expected == actual + + def test_verify_email_after_update(self): + participant = self.make_participant('alice', claimed_time="now") + self.change_email_address('alice@gmail.com', participant.username) + hash_string = Participant.from_username(participant.username).email.hash + self.verify_email(participant.username,hash_string) + self.change_email_address('alice@yahoo.com', participant.username) + hash_string = Participant.from_username(participant.username).email.hash + self.verify_email(participant.username,hash_string) + expected = True + actual = Participant.from_username(participant.username).email.confirmed + assert expected == actual + + def test_hash_is_regenerated_on_update(self): + participant = self.make_participant('alice', claimed_time="now") + self.change_email_address('alice@gmail.com', participant.username) + hash_string_1 = Participant.from_username(participant.username).email.hash + self.change_email_address('alice@gmail.com', participant.username) + hash_string_2 = Participant.from_username(participant.username).email.hash + assert hash_string_1 != hash_string_2 \ No newline at end of file diff --git a/www/%username/verify-email.html.spt b/www/%username/verify-email.html.spt new file mode 100644 index 0000000000..026fbfcadf --- /dev/null +++ b/www/%username/verify-email.html.spt @@ -0,0 +1,53 @@ +"""Verify a participant's email +""" +from gratipay.utils import get_participant +from aspen import Response +from aspen.utils import utcnow +from datetime import timedelta + +[-----------------------------------------------------------------------------] + +participant = get_participant(request, restrict=False) +qs = request.line.uri.querystring +hash_string = qs['hash'] if 'hash' in qs else '' + +if not participant.email: + raise Response(404) + +CONFIRMED = participant.email.confirmed +original_hash = participant.email.hash if hasattr(participant.email, 'hash') else '' +email_ctime = participant.email.ctime if hasattr(participant.email, 'ctime') else '' + +EXPIRED = False + +if not CONFIRMED and hash_string == original_hash: + if utcnow() - email_ctime < timedelta(hours=24): + result = participant.update_email(participant.email.address, True) + CONFIRMED = result.confirmed + else: + EXPIRED = True + +[-----------------------------------------------------------------------------] +{% extends "templates/base.html" %} + +{% block scripts %} + +{% endblock %} + +{% block heading %} +

Verify Email

+{% endblock %} + +{% block box %} +
+ {% if ALREADY_CONFIRMED or CONFIRMED %} +

{{ _("Your email address has been verified.") }}

+ {% elif EXPIRED %} +

{{ _("Your verification email has expired.") }}

+ {% else %} +

{{ _("Failed to verify your email address") }}

+ {% endif %} + {{ _("Go to homepage") }} +
+{% endblock %} + From 301e94f302dcd0901bda3184669ddcd5dc25afb9 Mon Sep 17 00:00:00 2001 From: Rohit Paul Kuruvilla Date: Wed, 10 Sep 2014 01:31:17 +0530 Subject: [PATCH 004/107] Sending emails --- defaults.env | 2 +- gratipay/models/participant.py | 14 ++++++++-- gratipay/utils/emails.py | 50 ++++++++++++++++++++++++++++++++++ gratipay/wireup.py | 2 -- js/gratipay/account.js | 1 + 5 files changed, 64 insertions(+), 5 deletions(-) create mode 100644 gratipay/utils/emails.py diff --git a/defaults.env b/defaults.env index 5adef34f31..bce2b0142c 100644 --- a/defaults.env +++ b/defaults.env @@ -71,6 +71,6 @@ ASPEN_WWW_ROOT=www/ # https://github.com/benoitc/gunicorn/issues/186 GUNICORN_OPTS="--workers=1 --timeout=99999999" -MANDRILL_KEY= +MANDRILL_KEY=eQeaTXtlBIuoBKb5ymL0aA RAISE_CARD_EXPIRATION=no diff --git a/gratipay/models/participant.py b/gratipay/models/participant.py index f0cc7e441a..d4bf0df617 100644 --- a/gratipay/models/participant.py +++ b/gratipay/models/participant.py @@ -38,6 +38,7 @@ from gratipay.models.account_elsewhere import AccountElsewhere from gratipay.utils.username import safely_reserve_a_username from gratipay.utils import is_card_expiring +from gratipay.utils.emails import send_verification_email ASCII_ALLOWED_IN_USERNAME = set("0123456789" @@ -551,19 +552,28 @@ def update_email(self, email, confirmed=False): current_email = self.email.address if hasattr(self.email,'address') else '' ctime = self.email.ctime if hasattr(self.email,'ctime') else utcnow() was_confirmed = self.email.confirmed if hasattr(self.email,'confirmed') else '' - if (email != current_email) or (email == current_email and confirmed == was_confirmed == False): + should_verify = (email != current_email) or (email == current_email and confirmed == was_confirmed == False) + if should_verify: confirmed = False hash_string = str(uuid.uuid4()) ctime = utcnow() - # Send the user an email here with self.db.get_cursor() as c: add_event(c, 'participant', dict(id=self.id, action='set', values=dict(current_email=email))) r = c.one("UPDATE participants SET email = ROW(%s, %s, %s, %s) WHERE username=%s RETURNING email" , (email, confirmed, hash_string, ctime,self.username) ) self.set_attributes(email=r) + if should_verify: + send_verification_email(self) return r + def get_verification_link(self): + hash_string = self.email.hash + username = self.username_lower + link = "%s://%s/%s/verify-email.html?hash=%s" % (gratipay.canonical_scheme, gratipay.canonical_host, username, hash_string) + return link + + def update_goal(self, goal): typecheck(goal, (Decimal, None)) with self.db.get_cursor() as c: diff --git a/gratipay/utils/emails.py b/gratipay/utils/emails.py new file mode 100644 index 0000000000..9b7b7b859d --- /dev/null +++ b/gratipay/utils/emails.py @@ -0,0 +1,50 @@ +import mandrill +from environment import Environment +from aspen import log_dammit + +class BadEnvironment(SystemExit): + pass + +def env(): + env = Environment(MANDRILL_KEY=unicode) + if env.malformed: + raise BadEnvironment("Malformed envvar: MANDRILL_KEY") + if env.missing: + raise BadEnvironment("Missing envvar: MANDRILL_KEY") + return env + +class MandrillError(Exception): pass + +def mail(env): + mandrill_client = mandrill.Mandrill(env.mandrill_key) + return mandrill_client + +def send_email(to_address, to_name, subject, body): + mail_client = mail(env()) + message = { + 'from_email': 'notifications@gratipay.com', + 'from_name': 'Gratipay', + 'to': [{'email': to_address, + 'name': to_name + }], + 'subject': subject, + 'html': body + } + try: + result = mail_client.messages.send(message=message) + return result + except mandrill.Error, e: + log_dammit('A mandrill error occurred: %s - %s' % (e.__class__, e)) + raise MandrillError + +def send_verification_email(participant): + subject = "Welcome to Gratipay!" + link = participant.get_verification_link() + # TODO - Improve body text + body = """ + Welcome to Gratipay! + + Click on this link to verify your email. + + """ % link + return send_email(participant.email.address, participant.username, subject, body) diff --git a/gratipay/wireup.py b/gratipay/wireup.py index e2f9b28080..270ee90736 100644 --- a/gratipay/wireup.py +++ b/gratipay/wireup.py @@ -35,7 +35,6 @@ from gratipay.utils.cache_static import asset_etag from gratipay.utils.i18n import ALIASES, ALIASES_R, get_function_from_rule, strip_accents - def canonical(env): gratipay.canonical_scheme = env.canonical_scheme gratipay.canonical_host = env.canonical_host @@ -56,7 +55,6 @@ def db(env): def mail(env): mandrill_client = mandrill.Mandrill(env.mandrill_key) - return mandrill_client def billing(env): diff --git a/js/gratipay/account.js b/js/gratipay/account.js index 48d9f9856d..7dc098ff64 100644 --- a/js/gratipay/account.js +++ b/js/gratipay/account.js @@ -137,6 +137,7 @@ Gratipay.account.init = function() { $('.email-address').text(data.email); $('.email').toggle(); $('.toggle-email').show(); + Gratipay.notification('Your email address has been changed', 'notice'); if (data.email === '') { $('.toggle-email').text('+ Add'); // TODO i18n } else { From cdd872faf64d730855625ebeae8e82da84b31798 Mon Sep 17 00:00:00 2001 From: Rohit Paul Kuruvilla Date: Thu, 11 Sep 2014 00:41:22 +0530 Subject: [PATCH 005/107] Account page UI - email verification --- gratipay/models/participant.py | 3 ++- js/gratipay/account.js | 5 ++++- www/%username/account/index.html.spt | 5 +++++ www/%username/email.json.spt | 4 ++-- www/%username/verify-email.html.spt | 2 +- 5 files changed, 14 insertions(+), 5 deletions(-) diff --git a/gratipay/models/participant.py b/gratipay/models/participant.py index d4bf0df617..a803361e9a 100644 --- a/gratipay/models/participant.py +++ b/gratipay/models/participant.py @@ -553,6 +553,7 @@ def update_email(self, email, confirmed=False): ctime = self.email.ctime if hasattr(self.email,'ctime') else utcnow() was_confirmed = self.email.confirmed if hasattr(self.email,'confirmed') else '' should_verify = (email != current_email) or (email == current_email and confirmed == was_confirmed == False) + confirmed = True if should_verify: confirmed = False hash_string = str(uuid.uuid4()) @@ -560,7 +561,7 @@ def update_email(self, email, confirmed=False): with self.db.get_cursor() as c: add_event(c, 'participant', dict(id=self.id, action='set', values=dict(current_email=email))) r = c.one("UPDATE participants SET email = ROW(%s, %s, %s, %s) WHERE username=%s RETURNING email" - , (email, confirmed, hash_string, ctime,self.username) + , (email, confirmed, hash_string, ctime, self.username) ) self.set_attributes(email=r) if should_verify: diff --git a/js/gratipay/account.js b/js/gratipay/account.js index 7dc098ff64..bb336a1b82 100644 --- a/js/gratipay/account.js +++ b/js/gratipay/account.js @@ -137,12 +137,15 @@ Gratipay.account.init = function() { $('.email-address').text(data.email); $('.email').toggle(); $('.toggle-email').show(); - Gratipay.notification('Your email address has been changed', 'notice'); + Gratipay.notification('Your email address has been changed', 'success'); if (data.email === '') { $('.toggle-email').text('+ Add'); // TODO i18n } else { $('.toggle-email').text('Edit'); // TODO i18n } + if (!data.confirmed) { + $('#email-not-verified').show(); + } $this.css('opacity', 1); } diff --git a/www/%username/account/index.html.spt b/www/%username/account/index.html.spt index 2740e175c1..1c090d48f7 100644 --- a/www/%username/account/index.html.spt +++ b/www/%username/account/index.html.spt @@ -158,6 +158,11 @@ locked = False {{ participant.email.address }} {% endif %} + {% if participant.email and not participant.email.confirmed %} + {{ _("(Unverified)") }} + {% elif participant.email %} + + {% endif %}