diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e70b304..fa4f3024 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ All notable changes to this project will be documented in this file. - Upgrade to Django 2.2 - Can configure the email integration to only report the "down" events (#231) - Add "Test!" function in the Integrations page (#207) - +- Rate limiting for the "Log In" emails ## 1.6.0 - 2019-04-01 diff --git a/hc/accounts/tests/test_login.py b/hc/accounts/tests/test_login.py index fdc48bdd..b6fd5e97 100644 --- a/hc/accounts/tests/test_login.py +++ b/hc/accounts/tests/test_login.py @@ -1,6 +1,7 @@ from django.conf import settings from django.core import mail -from hc.api.models import Check +from django.test.utils import override_settings +from hc.api.models import Check, TokenBucket from hc.test import BaseTestCase @@ -32,6 +33,36 @@ class LoginTestCase(BaseTestCase): body = mail.outbox[0].body self.assertTrue("/?next=/integrations/add_slack/" in body) + @override_settings(SECRET_KEY="test-secret") + def test_it_rate_limits_emails(self): + # "d60d..." is sha1("alice@example.orgtest-secret") + obj = TokenBucket(value="em-d60db3b2343e713a4de3e92d4eb417e4f05f06ab") + obj.tokens = 0 + obj.save() + + form = {"identity": "alice@example.org"} + + r = self.client.post("/accounts/login/", form) + self.assertContains(r, "Too Many Requests") + + # No email should have been sent + self.assertEqual(len(mail.outbox), 0) + + @override_settings(SECRET_KEY="test-secret") + def test_it_rate_limits_ips(self): + # 4b84.... is sha1("127.0.0.1test-secret") + obj = TokenBucket(value="ip-4b84b15bff6ee5796152495a230e45e3d7e947d9") + obj.tokens = 0 + obj.save() + + form = {"identity": "alice@example.org"} + + r = self.client.post("/accounts/login/", form) + self.assertContains(r, "Too Many Requests") + + # No email should have been sent + self.assertEqual(len(mail.outbox), 0) + def test_it_pops_bad_link_from_session(self): self.client.session["bad_link"] = True self.client.get("/accounts/login/") diff --git a/hc/accounts/views.py b/hc/accounts/views.py index 1bf7a06d..f12e9a84 100644 --- a/hc/accounts/views.py +++ b/hc/accounts/views.py @@ -22,7 +22,7 @@ from hc.accounts.forms import (ChangeEmailForm, EmailPasswordForm, ProjectNameForm, AvailableEmailForm, ExistingEmailForm) from hc.accounts.models import Profile, Project, Member -from hc.api.models import Channel, Check +from hc.api.models import Channel, Check, TokenBucket from hc.payments.models import Subscription NEXT_WHITELIST = ("hc-checks", @@ -102,14 +102,18 @@ def login(request): else: magic_form = ExistingEmailForm(request.POST) if magic_form.is_valid(): - profile = Profile.objects.for_user(magic_form.user) + user = magic_form.user + if not TokenBucket.authorize_login_email(user.email): + return render(request, "try_later.html") + if not TokenBucket.authorize_login_ip(request): + return render(request, "try_later.html") redirect_url = request.GET.get("next") - if _is_whitelisted(redirect_url): - profile.send_instant_login_link(redirect_url=redirect_url) - else: - profile.send_instant_login_link() + if not _is_whitelisted(redirect_url): + redirect_url = None + profile = Profile.objects.for_user(user) + profile.send_instant_login_link(redirect_url=redirect_url) return redirect("hc-login-link-sent") bad_link = request.session.pop("bad_link", None) diff --git a/hc/api/migrations/0060_tokenbucket.py b/hc/api/migrations/0060_tokenbucket.py new file mode 100644 index 00000000..07c154d8 --- /dev/null +++ b/hc/api/migrations/0060_tokenbucket.py @@ -0,0 +1,22 @@ +# Generated by Django 2.2 on 2019-04-25 12:46 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0059_auto_20190314_1744'), + ] + + operations = [ + migrations.CreateModel( + name='TokenBucket', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('value', models.CharField(max_length=80, unique=True)), + ('tokens', models.FloatField(default=1.0)), + ('updated', models.DateTimeField(auto_now_add=True)), + ], + ), + ] diff --git a/hc/api/models.py b/hc/api/models.py index 9c97dbb2..ce131a5b 100644 --- a/hc/api/models.py +++ b/hc/api/models.py @@ -591,3 +591,56 @@ class Flip(models.Model): errors.append((channel, error)) return errors + + +class TokenBucket(models.Model): + value = models.CharField(max_length=80, unique=True) + tokens = models.FloatField(default=1.0) + updated = models.DateTimeField(default=timezone.now) + + @staticmethod + def authorize(value, capacity, refill_time_secs): + now = timezone.now() + obj, created = TokenBucket.objects.get_or_create(value=value) + + if not created: + # Top up the bucket: + delta_secs = (now - obj.updated).total_seconds() + obj.tokens = min(1.0, obj.tokens + delta_secs / refill_time_secs) + + obj.tokens -= 1.0 / capacity + if obj.tokens < 0: + # Not enough tokens + return False + + # Race condition: two concurrent authorize calls can overwrite each + # other's changes. It's OK to be a little inexact here for the sake + # of simplicity. + obj.updated = now + obj.save() + + return True + + @staticmethod + def authorize_login_email(email): + # remove dots and alias: + mailbox, domain = email.split("@") + mailbox = mailbox.replace(".", "") + mailbox = mailbox.split("+")[0] + email = mailbox + "@" + domain + + b = (email + settings.SECRET_KEY).encode() + value = "em-%s" % hashlib.sha1(b).hexdigest() + + # 20 emails per 3600 seconds (1 hour): + return TokenBucket.authorize(value, 20, 3600) + + @staticmethod + def authorize_login_ip(request): + headers = request.META + ip = headers.get("HTTP_X_FORWARDED_FOR", headers["REMOTE_ADDR"]) + ip = ip.split(",")[0] + value = "ip-%s" % hashlib.sha1(ip.encode()).hexdigest() + + # 20 login attempts from a single IP per 3600 seconds (1 hour): + return TokenBucket.authorize(value, 20, 3600) diff --git a/hc/api/tests/test_tokenbucket.py b/hc/api/tests/test_tokenbucket.py new file mode 100644 index 00000000..03506d33 --- /dev/null +++ b/hc/api/tests/test_tokenbucket.py @@ -0,0 +1,47 @@ +from datetime import timedelta as td + +from django.test.utils import override_settings +from django.utils.timezone import now +from hc.api.models import TokenBucket +from hc.test import BaseTestCase + +# This is sha1("alice@example.org" + "test-secred") +ALICE_HASH = "d60db3b2343e713a4de3e92d4eb417e4f05f06ab" + + +@override_settings(SECRET_KEY="test-secret") +class TokenBucketTestCase(BaseTestCase): + + def test_it_works(self): + r = TokenBucket.authorize_login_email("alice@example.org") + self.assertTrue(r) + + obj = TokenBucket.objects.get() + self.assertEqual(obj.tokens, 0.95) + self.assertEqual(obj.value, "em-" + ALICE_HASH) + + def test_it_handles_insufficient_tokens(self): + TokenBucket.objects.create(value="em-" + ALICE_HASH, tokens=0.04) + + r = TokenBucket.authorize_login_email("alice@example.org") + self.assertFalse(r) + + def test_it_tops_up(self): + obj = TokenBucket(value="em-" + ALICE_HASH) + obj.tokens = 0 + obj.updated = now() - td(minutes=30) + obj.save() + + r = TokenBucket.authorize_login_email("alice@example.org") + self.assertTrue(r) + + obj.refresh_from_db() + self.assertAlmostEqual(obj.tokens, 0.45, places=5) + + def test_it_normalizes_email(self): + emails = ("alice+alias@example.org", "a.li.ce@example.org") + + for email in emails: + TokenBucket.authorize_login_email(email) + + self.assertEqual(TokenBucket.objects.count(), 1) diff --git a/templates/try_later.html b/templates/try_later.html new file mode 100644 index 00000000..f4c953fb --- /dev/null +++ b/templates/try_later.html @@ -0,0 +1,14 @@ +{% extends "base.html" %} + +{% block content %} +
Please try again later.
+