Browse Source

Rate limiting for the "Log In" emails

pull/248/head
Pēteris Caune 6 years ago
parent
commit
aaa3b2748e
No known key found for this signature in database GPG Key ID: E28D7679E9A9EDE2
7 changed files with 179 additions and 8 deletions
  1. +1
    -1
      CHANGELOG.md
  2. +32
    -1
      hc/accounts/tests/test_login.py
  3. +10
    -6
      hc/accounts/views.py
  4. +22
    -0
      hc/api/migrations/0060_tokenbucket.py
  5. +53
    -0
      hc/api/models.py
  6. +47
    -0
      hc/api/tests/test_tokenbucket.py
  7. +14
    -0
      templates/try_later.html

+ 1
- 1
CHANGELOG.md View File

@ -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


+ 32
- 1
hc/accounts/tests/test_login.py View File

@ -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("[email protected]")
obj = TokenBucket(value="em-d60db3b2343e713a4de3e92d4eb417e4f05f06ab")
obj.tokens = 0
obj.save()
form = {"identity": "[email protected]"}
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": "[email protected]"}
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/")


+ 10
- 6
hc/accounts/views.py View File

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


+ 22
- 0
hc/api/migrations/0060_tokenbucket.py View File

@ -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
- 0
hc/api/models.py View File

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

+ 47
- 0
hc/api/tests/test_tokenbucket.py View File

@ -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("[email protected]" + "test-secred")
ALICE_HASH = "d60db3b2343e713a4de3e92d4eb417e4f05f06ab"
@override_settings(SECRET_KEY="test-secret")
class TokenBucketTestCase(BaseTestCase):
def test_it_works(self):
r = TokenBucket.authorize_login_email("[email protected]")
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("[email protected]")
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("[email protected]")
self.assertTrue(r)
obj.refresh_from_db()
self.assertAlmostEqual(obj.tokens, 0.45, places=5)
def test_it_normalizes_email(self):
emails = ("[email protected]", "[email protected]")
for email in emails:
TokenBucket.authorize_login_email(email)
self.assertEqual(TokenBucket.objects.count(), 1)

+ 14
- 0
templates/try_later.html View File

@ -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