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


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

@ -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("[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): 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/")


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

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