From b1e04723283a1080755051469de8450b09dab894 Mon Sep 17 00:00:00 2001 From: Paul Kuruvilla Date: Sat, 8 Jul 2017 22:20:22 +0530 Subject: [PATCH] Add funcs to handle email nonces --- gratipay/security/authentication/__init__.py | 3 + gratipay/security/authentication/email.py | 59 ++++++++++++++ .../authentication/test_email_signin.py | 77 +++++++++++++++++++ 3 files changed, 139 insertions(+) create mode 100644 gratipay/security/authentication/email.py create mode 100644 tests/py/security/authentication/test_email_signin.py diff --git a/gratipay/security/authentication/__init__.py b/gratipay/security/authentication/__init__.py index bb409a2f36..d02742db1b 100644 --- a/gratipay/security/authentication/__init__.py +++ b/gratipay/security/authentication/__init__.py @@ -1 +1,4 @@ +"""Gratipay authentication module. +""" + from __future__ import absolute_import, division, print_function, unicode_literals diff --git a/gratipay/security/authentication/email.py b/gratipay/security/authentication/email.py new file mode 100644 index 0000000000..cabb7ad7c0 --- /dev/null +++ b/gratipay/security/authentication/email.py @@ -0,0 +1,59 @@ +import datetime +import uuid + +from aspen.utils import utcnow + +class VerificationResult(object): + def __init__(self, name): + self.name = name + def __repr__(self): + return "" % self.name + __str__ = __repr__ + +#: Signal that the nonce doesn't exist in our database +NONCE_INVALID = VerificationResult('Invalid') + +#: Signal that the nonce exists, but has expired +NONCE_EXPIRED = VerificationResult('Expired') + +#: Signal that the nonce exists, and is valid +NONCE_VALID = VerificationResult('Valid') + +#: Time for nonce to expire +NONCE_EXPIRY_MINUTES = 60 + +def create_signin_nonce(db, email_address): + nonce = str(uuid.uuid4()) + db.run(""" + INSERT INTO email_auth_nonces (email_address, intent, nonce) + VALUES ( + %(email_address)s, + 'sign-in', + %(nonce)s + ) + """, locals()) + + return nonce + +def verify_nonce(db, email_address, nonce): + record = db.one(""" + SELECT email_address, ctime, intent + FROM email_auth_nonces + WHERE nonce = %(nonce)s + AND email_address = %(email_address)s + """, locals(), back_as=dict) + + if not record: + return NONCE_INVALID + + if utcnow() - record['ctime'] > datetime.timedelta(minutes=NONCE_EXPIRY_MINUTES): + return NONCE_EXPIRED + + return NONCE_VALID + +def invalidate_nonce(db, email_address, nonce): + db.run(""" + DELETE FROM email_auth_nonces + WHERE nonce = %(nonce)s + AND email_address = %(email_address)s + """, locals()) diff --git a/tests/py/security/authentication/test_email_signin.py b/tests/py/security/authentication/test_email_signin.py new file mode 100644 index 0000000000..874d89c249 --- /dev/null +++ b/tests/py/security/authentication/test_email_signin.py @@ -0,0 +1,77 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, division, print_function, unicode_literals +from uuid import UUID + +from psycopg2 import IntegrityError +import pytest + +from gratipay.security.authentication.email import create_signin_nonce, verify_nonce, invalidate_nonce +from gratipay.security.authentication.email import NONCE_VALID, NONCE_INVALID, NONCE_EXPIRED +from gratipay.testing import Harness + +class TestCreateSigninNonce(Harness): + + def setUp(self): + alice = self.make_participant('alice') + self.add_and_verify_email(alice, 'alice@gratipay.com') + + def _fetch_rec_from_db(self, nonce): + return self.db.one("SELECT * FROM email_auth_nonces WHERE nonce = %s", (nonce, ), back_as=dict) + + def test_inserts_into_db(self): + nonce = create_signin_nonce(self.db, 'alice@gratipay.com') + rec = self._fetch_rec_from_db(nonce) + + assert rec['nonce'] == nonce + assert rec['intent'] == 'sign-in' + assert rec['email_address'] == 'alice@gratipay.com' + + def test_nonce_is_valid_uuid(self): + nonce = create_signin_nonce(self.db, 'alice@gratipay.com') + + assert UUID(nonce, version=4).__class__ == UUID + + +class TestVerifyNonce(Harness): + + def setUp(self): + alice = self.make_participant('alice') + self.add_and_verify_email(alice, 'alice@gratipay.com') + + def test_valid_nonce(self): + nonce = create_signin_nonce(self.db, 'alice@gratipay.com') + assert verify_nonce(self.db, 'alice@gratipay.com', nonce) == NONCE_VALID + + def test_expired_nonce(self): + nonce = create_signin_nonce(self.db, 'alice@gratipay.com') + self.db.run("UPDATE email_auth_nonces SET ctime = ctime - interval '1 day'") + assert verify_nonce(self.db, 'alice@gratipay.com', nonce) == NONCE_EXPIRED + + def test_invalid_nonce(self): + create_signin_nonce(self.db, 'alice@gratipay.com') + assert verify_nonce(self.db, 'alice@gratipay.com', "dummy_nonce") == NONCE_INVALID + +class TestInvalidateNonce(Harness): + + def setUp(self): + alice = self.make_participant('alice') + self.add_and_verify_email(alice, 'alice@gratipay.com') + + def test_deletes_nonce(self): + nonce = create_signin_nonce(self.db, 'alice@gratipay.com') + invalidate_nonce(self.db, 'alice@gratipay.com', nonce) + + assert verify_nonce(self.db, 'alice@gratipay.com', nonce) == NONCE_INVALID + + def test_only_deletes_one_nonce(self): + nonce1 = create_signin_nonce(self.db, 'alice@gratipay.com') + nonce2 = create_signin_nonce(self.db, 'alice@gratipay.com') + invalidate_nonce(self.db, 'alice@gratipay.com', nonce1) + + assert verify_nonce(self.db, 'alice@gratipay.com', nonce1) == NONCE_INVALID + assert verify_nonce(self.db, 'alice@gratipay.com', nonce2) == NONCE_VALID + + def test_tolerates_invalidated_nonce(self): + nonce = create_signin_nonce(self.db, 'alice@gratipay.com') + invalidate_nonce(self.db, 'alice@gratipay.com', nonce) + invalidate_nonce(self.db, 'alice@gratipay.com', nonce) # Should not throw an error