Browse Source

Rate limit login-with-password attempts.

pull/248/head
Pēteris Caune 6 years ago
parent
commit
afaa8767cd
No known key found for this signature in database GPG Key ID: E28D7679E9A9EDE2
6 changed files with 51 additions and 53 deletions
  1. +1
    -1
      CHANGELOG.md
  2. +11
    -7
      hc/accounts/forms.py
  3. +17
    -16
      hc/accounts/tests/test_login.py
  4. +7
    -13
      hc/accounts/views.py
  5. +8
    -11
      hc/api/models.py
  6. +7
    -5
      templates/accounts/login.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
- Rate limiting for the log in attempts
## 1.6.0 - 2019-04-01 ## 1.6.0 - 2019-04-01


+ 11
- 7
hc/accounts/forms.py View File

@ -1,8 +1,8 @@
from datetime import timedelta as td from datetime import timedelta as td
from django import forms from django import forms
from django.contrib.auth import authenticate from django.contrib.auth import authenticate
from django.contrib.auth.models import User from django.contrib.auth.models import User
from hc.api.models import TokenBucket
class LowercaseEmailField(forms.EmailField): class LowercaseEmailField(forms.EmailField):
@ -25,13 +25,16 @@ class AvailableEmailForm(forms.Form):
return v return v
class ExistingEmailForm(forms.Form):
class EmailLoginForm(forms.Form):
# Call it "identity" instead of "email" # Call it "identity" instead of "email"
# to avoid some of the dumber bots # to avoid some of the dumber bots
identity = LowercaseEmailField() identity = LowercaseEmailField()
def clean_identity(self): def clean_identity(self):
v = self.cleaned_data["identity"] v = self.cleaned_data["identity"]
if not TokenBucket.authorize_login_email(v):
raise forms.ValidationError("Too many attempts, please try later.")
try: try:
self.user = User.objects.get(email=v) self.user = User.objects.get(email=v)
except User.DoesNotExist: except User.DoesNotExist:
@ -40,7 +43,7 @@ class ExistingEmailForm(forms.Form):
return v return v
class EmailPasswordForm(forms.Form):
class PasswordLoginForm(forms.Form):
email = LowercaseEmailField() email = LowercaseEmailField()
password = forms.CharField() password = forms.CharField()
@ -49,11 +52,12 @@ class EmailPasswordForm(forms.Form):
password = self.cleaned_data.get('password') password = self.cleaned_data.get('password')
if username and password: if username and password:
if not TokenBucket.authorize_login_password(username):
raise forms.ValidationError("Too many attempts, please try later.")
self.user = authenticate(username=username, password=password) self.user = authenticate(username=username, password=password)
if self.user is None:
raise forms.ValidationError("Incorrect email or password")
if not self.user.is_active:
raise forms.ValidationError("Account is inactive")
if self.user is None or not self.user.is_active:
raise forms.ValidationError("Incorrect email or password.")
return self.cleaned_data return self.cleaned_data


+ 17
- 16
hc/accounts/tests/test_login.py View File

@ -43,22 +43,7 @@ class LoginTestCase(BaseTestCase):
form = {"identity": "[email protected]"} form = {"identity": "[email protected]"}
r = self.client.post("/accounts/login/", form) 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):
# 60be.... is sha1("127.0.0.1test-secret")
obj = TokenBucket(value="ip-60be45f44bd9ab3805871fb1137594e708c993ff")
obj.tokens = 0
obj.save()
form = {"identity": "[email protected]"}
r = self.client.post("/accounts/login/", form)
self.assertContains(r, "Too Many Requests")
self.assertContains(r, "Too many attempts")
# No email should have been sent # No email should have been sent
self.assertEqual(len(mail.outbox), 0) self.assertEqual(len(mail.outbox), 0)
@ -87,6 +72,22 @@ class LoginTestCase(BaseTestCase):
r = self.client.post("/accounts/login/", form) r = self.client.post("/accounts/login/", form)
self.assertRedirects(r, self.checks_url) self.assertRedirects(r, self.checks_url)
@override_settings(SECRET_KEY="test-secret")
def test_it_rate_limits_password_attempts(self):
# "d60d..." is sha1("[email protected]")
obj = TokenBucket(value="pw-d60db3b2343e713a4de3e92d4eb417e4f05f06ab")
obj.tokens = 0
obj.save()
form = {
"action": "login",
"email": "[email protected]",
"password": "password"
}
r = self.client.post("/accounts/login/", form)
self.assertContains(r, "Too many attempts")
def test_it_handles_password_login_with_redirect(self): def test_it_handles_password_login_with_redirect(self):
check = Check.objects.create(project=self.project) check = Check.objects.create(project=self.project)


+ 7
- 13
hc/accounts/views.py View File

@ -16,11 +16,11 @@ from django.utils.timezone import now
from django.urls import resolve, Resolver404 from django.urls import resolve, Resolver404
from django.views.decorators.csrf import csrf_exempt from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST from django.views.decorators.http import require_POST
from hc.accounts.forms import (ChangeEmailForm, EmailPasswordForm,
from hc.accounts.forms import (ChangeEmailForm, PasswordLoginForm,
InviteTeamMemberForm, RemoveTeamMemberForm, InviteTeamMemberForm, RemoveTeamMemberForm,
ReportSettingsForm, SetPasswordForm, ReportSettingsForm, SetPasswordForm,
ProjectNameForm, AvailableEmailForm, ProjectNameForm, AvailableEmailForm,
ExistingEmailForm)
EmailLoginForm)
from hc.accounts.models import Profile, Project, Member from hc.accounts.models import Profile, Project, Member
from hc.api.models import Channel, Check, TokenBucket from hc.api.models import Channel, Check, TokenBucket
from hc.payments.models import Subscription from hc.payments.models import Subscription
@ -89,30 +89,24 @@ def _redirect_after_login(request):
def login(request): def login(request):
form = EmailPasswordForm()
magic_form = ExistingEmailForm()
form = PasswordLoginForm()
magic_form = EmailLoginForm()
if request.method == 'POST': if request.method == 'POST':
if request.POST.get("action") == "login": if request.POST.get("action") == "login":
form = EmailPasswordForm(request.POST)
form = PasswordLoginForm(request.POST)
if form.is_valid(): if form.is_valid():
auth_login(request, form.user) auth_login(request, form.user)
return _redirect_after_login(request) return _redirect_after_login(request)
else: else:
magic_form = ExistingEmailForm(request.POST)
magic_form = EmailLoginForm(request.POST)
if magic_form.is_valid(): if magic_form.is_valid():
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 not _is_whitelisted(redirect_url): if not _is_whitelisted(redirect_url):
redirect_url = None redirect_url = None
profile = Profile.objects.for_user(user)
profile = Profile.objects.for_user(magic_form.user)
profile.send_instant_login_link(redirect_url=redirect_url) profile.send_instant_login_link(redirect_url=redirect_url)
return redirect("hc-login-link-sent") return redirect("hc-login-link-sent")


+ 8
- 11
hc/api/models.py View File

@ -635,20 +635,17 @@ class TokenBucket(models.Model):
# 20 login attempts for a single email per hour: # 20 login attempts for a single email per hour:
return TokenBucket.authorize(value, 20, 3600) 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]
salted_encoded = (ip + settings.SECRET_KEY).encode()
value = "ip-%s" % hashlib.sha1(salted_encoded).hexdigest()
# 20 login attempts from a single IP per hour:
return TokenBucket.authorize(value, 20, 3600)
@staticmethod @staticmethod
def authorize_invite(user): def authorize_invite(user):
value = "invite-%d" % user.id value = "invite-%d" % user.id
# 20 invites per day # 20 invites per day
return TokenBucket.authorize(value, 2, 3600 * 24)
@staticmethod
def authorize_login_password(email):
salted_encoded = (email + settings.SECRET_KEY).encode()
value = "pw-%s" % hashlib.sha1(salted_encoded).hexdigest()
# 20 password attempts per day
return TokenBucket.authorize(value, 20, 3600 * 24) return TokenBucket.authorize(value, 20, 3600 * 24)

+ 7
- 5
templates/accounts/login.html View File

@ -20,8 +20,8 @@
<form id="magic-link-form" method="post"> <form id="magic-link-form" method="post">
{% csrf_token %} {% csrf_token %}
{% if magic_form.errors %}
<p class="text-danger">Incorrect email address.</p>
{% if magic_form.identity.errors %}
<p class="text-danger">{{ magic_form.identity.errors|join:"" }}</p>
{% else %} {% else %}
<p>Enter your <strong>email address</strong>.</p> <p>Enter your <strong>email address</strong>.</p>
{% endif %} {% endif %}
@ -30,7 +30,7 @@
type="email" type="email"
class="form-control input-lg" class="form-control input-lg"
name="identity" name="identity"
value="{{ magic_form.email.value|default:"" }}"
value="{{ magic_form.identity.value|default:"" }}"
placeholder="[email protected]" placeholder="[email protected]"
autocomplete="email"> autocomplete="email">
@ -56,8 +56,10 @@
{% csrf_token %} {% csrf_token %}
<input type="hidden" name="action" value="login" /> <input type="hidden" name="action" value="login" />
{% if form.errors %}
<p class="text-danger">Incorrect email or password.</p>
{% if form.non_field_errors %}
<p class="text-danger">{{ form.non_field_errors|join:"" }}</p>
{% elif form.errors %}
<p class="text-danger">Incorrect email or password.</p>
{% else %} {% else %}
<p> <p>
Enter your <strong>email address</strong> and <strong>password</strong>. Enter your <strong>email address</strong> and <strong>password</strong>.


Loading…
Cancel
Save