forked from GithubBackups/healthchecks
Rate limiting for the "Log In" emails
This commit is contained in:
parent
76479714a4
commit
aaa3b2748e
@ -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
|
||||
|
||||
|
@ -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/")
|
||||
|
@ -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
hc/api/migrations/0060_tokenbucket.py
Normal file
22
hc/api/migrations/0060_tokenbucket.py
Normal 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)),
|
||||
],
|
||||
),
|
||||
]
|
@ -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
hc/api/tests/test_tokenbucket.py
Normal file
47
hc/api/tests/test_tokenbucket.py
Normal 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("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
Normal file
14
templates/try_later.html
Normal 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…
x
Reference in New Issue
Block a user