Skip to content

Commit

Permalink
Merge pull request #78 from PlaidWeb/feature/18-email-throttle
Browse files Browse the repository at this point in the history
Track if there's already a pending login email
  • Loading branch information
fluffy-critter committed Aug 1, 2020
2 parents e4d7afe + 52640e4 commit 919a25c
Show file tree
Hide file tree
Showing 2 changed files with 78 additions and 8 deletions.
35 changes: 27 additions & 8 deletions authl/handlers/email_addr.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
import time
import urllib.parse

import expiringdict
import validate_email

from .. import disposition, tokens, utils
Expand All @@ -34,9 +35,6 @@
"""

DEFAULT_WAIT_ERROR = """An email has already been sent to {email}. Please be
patient; you may try again in {minutes} minutes."""


class EmailAddress(Handler):
""" Email via "magic link" """
Expand Down Expand Up @@ -67,9 +65,9 @@ def __init__(self,
sendmail,
notify_cdata,
token_store: tokens.TokenStore,
expires_time=None,
email_template_text=DEFAULT_TEMPLATE_TEXT,
please_wait_error=DEFAULT_WAIT_ERROR,
expires_time: int = None,
pending_storage: dict = None,
email_template_text: str = DEFAULT_TEMPLATE_TEXT,
):
""" Instantiate a magic link email handler.
Expand All @@ -78,8 +76,12 @@ def __init__(self,
the From and Subject headers before it sends.
:param notify_cdata: the callback data to provide to the user for the
next step instructions
:param tokens.TokenStore token_store: Storage for the identity tokens
:param int expires_time: how long the email link should be valid for, in
seconds (default: 900)
:param dict pending_storage: Storage to keep track of pending email addresses,
for DDOS/abuse mitigation. Defaults to an ExpiringDict that expires
after ``expires_time``
:param str email_template_text: the plaintext template for the sent
email, provided as a template string
Expand All @@ -93,10 +95,12 @@ def __init__(self,
# pylint:disable=too-many-arguments
self._sendmail = sendmail
self._email_template_text = email_template_text
self._wait_error = please_wait_error
self._cdata = notify_cdata
self._token_store = token_store
self._lifetime = expires_time or 900
self._pending = expiringdict.ExpiringDict(
max_len=1024,
max_age_seconds=self._lifetime) if pending_storage is None else pending_storage

def handles_url(self, url):
"""
Expand All @@ -122,7 +126,21 @@ def initiate_auth(self, id_url, callback_uri, redir):
return disposition.Error("Malformed email URL", redir)
dest_addr = parsed.path.lower()

if dest_addr in self._pending:
try:
_, _, when = self._token_store.get(self._pending[dest_addr])
if time.time() <= when + self._lifetime:
# There is already a pending valid token, so just remind them to
# check their email again
return disposition.Notify(self._cdata)
except (KeyError, ValueError):
pass

# The token has expired, so remove the pending token
self._pending.pop(dest_addr, None)

token = self._token_store.put((dest_addr, redir, time.time()))
self._pending[dest_addr] = token

link_url = (callback_uri + ('&' if '?' in callback_uri else '?') +
urllib.parse.urlencode({'t': token}))
Expand Down Expand Up @@ -150,6 +168,8 @@ def check_callback(self, url, get, data):
except (KeyError, ValueError):
return disposition.Error('Invalid token', '')

self._pending.pop(email_addr, None)

if time.time() > when + self._lifetime:
return disposition.Error("Login timed out", redir)

Expand Down Expand Up @@ -249,7 +269,6 @@ def from_config(config, token_store: tokens.TokenStore):
* ``EMAIL_EXPIRE_TIME``: How long a login email is valid for, in seconds
(defaults to the :py:class:`EmailAddress` default value)
:param tokens.TokenStore token_store: the authentication token storage
mechanism; see :py:mod:`authl.tokens` for more information.
Expand Down
51 changes: 51 additions & 0 deletions tests/handlers/test_emailaddr.py
Original file line number Diff line number Diff line change
Expand Up @@ -184,3 +184,54 @@ def test_from_config():

assert len(store) == 1
mock_smtp.assert_called_with('smtp.example.com', 587)


def test_please_wait():
token_store = tokens.DictStore()
pending = {}
mock_send = unittest.mock.MagicMock()
handler = email_addr.EmailAddress(mock_send, "this is data", token_store,
expires_time=60,
pending_storage=pending)

with unittest.mock.patch('time.time') as mock_time:
assert mock_send.call_count == 0
mock_time.return_value = 10

# First auth should call mock_send
handler.initiate_auth('mailto:foo@bar.com', 'http://example/', 'blop')
assert mock_send.call_count == 1
assert 'foo@bar.com' in pending
token_value = pending['foo@bar.com']

# Second auth should not
handler.initiate_auth('mailto:foo@bar.com', 'http://example/', 'blop')
assert mock_send.call_count == 1
assert 'foo@bar.com' in pending
assert token_value == pending['foo@bar.com']

# Using the link should remove the pending item
handler.check_callback('http://example/', {'t': pending['foo@bar.com']}, {})
assert 'foo@bar.com' not in pending

# Next auth should call mock_send again
handler.initiate_auth('mailto:foo@bar.com', 'http://example/', 'blop')
assert mock_send.call_count == 2
assert 'foo@bar.com' in pending
assert token_value != pending['foo@bar.com']
token_value = pending['foo@bar.com']

# Timing out the token should cause it to send again
mock_time.return_value = 1000
handler.initiate_auth('mailto:foo@bar.com', 'http://example/', 'blop')
assert mock_send.call_count == 3
assert 'foo@bar.com' in pending
assert token_value != pending['foo@bar.com']
token_value = pending['foo@bar.com']

# And anything else that removes the token from the token_store should as well
token_store.remove(pending['foo@bar.com'])
handler.initiate_auth('mailto:foo@bar.com', 'http://example/', 'blop')
assert mock_send.call_count == 4
assert token_value != pending['foo@bar.com']
token_value = pending['foo@bar.com']

0 comments on commit 919a25c

Please sign in to comment.