Browse Source

Rate limiting for the "Log In" emails

pull/248/head
Pēteris Caune 5 years ago
parent
commit
aaa3b2748e
No known key found for this signature in database GPG Key ID: E28D7679E9A9EDE2
  1. 2
      CHANGELOG.md
  2. 33
      hc/accounts/tests/test_login.py
  3. 16
      hc/accounts/views.py
  4. 22
      hc/api/migrations/0060_tokenbucket.py
  5. 53
      hc/api/models.py
  6. 47
      hc/api/tests/test_tokenbucket.py
  7. 14
      templates/try_later.html

2
CHANGELOG.md

@ -9,7 +9,7 @@ All notable changes to this project will be documented in this file.
- Upgrade to Django 2.2 - Upgrade to Django 2.2
- Can configure the email integration to only report the "down" events (#231) - Can configure the email integration to only report the "down" events (#231)
- Add "Test!" function in the Integrations page (#207) - Add "Test!" function in the Integrations page (#207)
- Rate limiting for the "Log In" emails
## 1.6.0 - 2019-04-01 ## 1.6.0 - 2019-04-01

33
hc/accounts/tests/test_login.py

@ -1,6 +1,7 @@
from django.conf import settings from django.conf import settings
from django.core import mail 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 from hc.test import BaseTestCase
@ -32,6 +33,36 @@ class LoginTestCase(BaseTestCase):
body = mail.outbox[0].body body = mail.outbox[0].body
self.assertTrue("/?next=/integrations/add_slack/" in 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): def test_it_pops_bad_link_from_session(self):
self.client.session["bad_link"] = True self.client.session["bad_link"] = True
self.client.get("/accounts/login/") self.client.get("/accounts/login/")

16
hc/accounts/views.py

@ -22,7 +22,7 @@ from hc.accounts.forms import (ChangeEmailForm, EmailPasswordForm,
ProjectNameForm, AvailableEmailForm, ProjectNameForm, AvailableEmailForm,
ExistingEmailForm) ExistingEmailForm)
from hc.accounts.models import Profile, Project, Member 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 from hc.payments.models import Subscription
NEXT_WHITELIST = ("hc-checks", NEXT_WHITELIST = ("hc-checks",
@ -102,14 +102,18 @@ def login(request):
else: else:
magic_form = ExistingEmailForm(request.POST) magic_form = ExistingEmailForm(request.POST)
if magic_form.is_valid(): 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") 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") return redirect("hc-login-link-sent")
bad_link = request.session.pop("bad_link", None) bad_link = request.session.pop("bad_link", None)

22
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)),
],
),
]

53
hc/api/models.py

@ -591,3 +591,56 @@ class Flip(models.Model):
errors.append((channel, error)) errors.append((channel, error))
return errors 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)

47
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)

14
templates/try_later.html

@ -0,0 +1,14 @@
{% extends "base.html" %}
{% block content %}
<div class="row">
<div class="col-sm-6 col-sm-offset-3">
<div class="hc-dialog text-center">
<h1>Too Many Requests</h1>
<div class="dialog-body">
<p>Please try again later.</p>
</div>
</div>
</div>
</div>
{% endblock %}
Loading…
Cancel
Save