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
|
- 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
|
||||||
|
|
||||||
|
@ -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/")
|
||||||
|
@ -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):
|
if not _is_whitelisted(redirect_url):
|
||||||
profile.send_instant_login_link(redirect_url=redirect_url)
|
redirect_url = None
|
||||||
else:
|
|
||||||
profile.send_instant_login_link()
|
|
||||||
|
|
||||||
|
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
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))
|
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
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