forked from GithubBackups/healthchecks
Rate limit login-with-password attempts.
This commit is contained in:
parent
beae8e62b4
commit
afaa8767cd
@ -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
|
||||
- Rate limiting for the log in attempts
|
||||
|
||||
## 1.6.0 - 2019-04-01
|
||||
|
||||
|
@ -1,8 +1,8 @@
|
||||
from datetime import timedelta as td
|
||||
from django import forms
|
||||
|
||||
from django.contrib.auth import authenticate
|
||||
from django.contrib.auth.models import User
|
||||
from hc.api.models import TokenBucket
|
||||
|
||||
|
||||
class LowercaseEmailField(forms.EmailField):
|
||||
@ -25,13 +25,16 @@ class AvailableEmailForm(forms.Form):
|
||||
return v
|
||||
|
||||
|
||||
class ExistingEmailForm(forms.Form):
|
||||
class EmailLoginForm(forms.Form):
|
||||
# Call it "identity" instead of "email"
|
||||
# to avoid some of the dumber bots
|
||||
identity = LowercaseEmailField()
|
||||
|
||||
def clean_identity(self):
|
||||
v = self.cleaned_data["identity"]
|
||||
if not TokenBucket.authorize_login_email(v):
|
||||
raise forms.ValidationError("Too many attempts, please try later.")
|
||||
|
||||
try:
|
||||
self.user = User.objects.get(email=v)
|
||||
except User.DoesNotExist:
|
||||
@ -40,7 +43,7 @@ class ExistingEmailForm(forms.Form):
|
||||
return v
|
||||
|
||||
|
||||
class EmailPasswordForm(forms.Form):
|
||||
class PasswordLoginForm(forms.Form):
|
||||
email = LowercaseEmailField()
|
||||
password = forms.CharField()
|
||||
|
||||
@ -49,11 +52,12 @@ class EmailPasswordForm(forms.Form):
|
||||
password = self.cleaned_data.get('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)
|
||||
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
|
||||
|
||||
|
@ -43,22 +43,7 @@ class LoginTestCase(BaseTestCase):
|
||||
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):
|
||||
# 60be.... is sha1("127.0.0.1test-secret")
|
||||
obj = TokenBucket(value="ip-60be45f44bd9ab3805871fb1137594e708c993ff")
|
||||
obj.tokens = 0
|
||||
obj.save()
|
||||
|
||||
form = {"identity": "alice@example.org"}
|
||||
|
||||
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
|
||||
self.assertEqual(len(mail.outbox), 0)
|
||||
@ -87,6 +72,22 @@ class LoginTestCase(BaseTestCase):
|
||||
r = self.client.post("/accounts/login/", form)
|
||||
self.assertRedirects(r, self.checks_url)
|
||||
|
||||
@override_settings(SECRET_KEY="test-secret")
|
||||
def test_it_rate_limits_password_attempts(self):
|
||||
# "d60d..." is sha1("alice@example.orgtest-secret")
|
||||
obj = TokenBucket(value="pw-d60db3b2343e713a4de3e92d4eb417e4f05f06ab")
|
||||
obj.tokens = 0
|
||||
obj.save()
|
||||
|
||||
form = {
|
||||
"action": "login",
|
||||
"email": "alice@example.org",
|
||||
"password": "password"
|
||||
}
|
||||
|
||||
r = self.client.post("/accounts/login/", form)
|
||||
self.assertContains(r, "Too many attempts")
|
||||
|
||||
def test_it_handles_password_login_with_redirect(self):
|
||||
check = Check.objects.create(project=self.project)
|
||||
|
||||
|
@ -16,11 +16,11 @@ from django.utils.timezone import now
|
||||
from django.urls import resolve, Resolver404
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
from django.views.decorators.http import require_POST
|
||||
from hc.accounts.forms import (ChangeEmailForm, EmailPasswordForm,
|
||||
from hc.accounts.forms import (ChangeEmailForm, PasswordLoginForm,
|
||||
InviteTeamMemberForm, RemoveTeamMemberForm,
|
||||
ReportSettingsForm, SetPasswordForm,
|
||||
ProjectNameForm, AvailableEmailForm,
|
||||
ExistingEmailForm)
|
||||
EmailLoginForm)
|
||||
from hc.accounts.models import Profile, Project, Member
|
||||
from hc.api.models import Channel, Check, TokenBucket
|
||||
from hc.payments.models import Subscription
|
||||
@ -89,30 +89,24 @@ def _redirect_after_login(request):
|
||||
|
||||
|
||||
def login(request):
|
||||
form = EmailPasswordForm()
|
||||
magic_form = ExistingEmailForm()
|
||||
form = PasswordLoginForm()
|
||||
magic_form = EmailLoginForm()
|
||||
|
||||
if request.method == 'POST':
|
||||
if request.POST.get("action") == "login":
|
||||
form = EmailPasswordForm(request.POST)
|
||||
form = PasswordLoginForm(request.POST)
|
||||
if form.is_valid():
|
||||
auth_login(request, form.user)
|
||||
return _redirect_after_login(request)
|
||||
|
||||
else:
|
||||
magic_form = ExistingEmailForm(request.POST)
|
||||
magic_form = EmailLoginForm(request.POST)
|
||||
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")
|
||||
if not _is_whitelisted(redirect_url):
|
||||
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)
|
||||
return redirect("hc-login-link-sent")
|
||||
|
||||
|
@ -635,20 +635,17 @@ class TokenBucket(models.Model):
|
||||
# 20 login attempts for a single email per 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]
|
||||
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
|
||||
def authorize_invite(user):
|
||||
value = "invite-%d" % user.id
|
||||
|
||||
# 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)
|
||||
|
@ -20,8 +20,8 @@
|
||||
<form id="magic-link-form" method="post">
|
||||
{% 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 %}
|
||||
<p>Enter your <strong>email address</strong>.</p>
|
||||
{% endif %}
|
||||
@ -30,7 +30,7 @@
|
||||
type="email"
|
||||
class="form-control input-lg"
|
||||
name="identity"
|
||||
value="{{ magic_form.email.value|default:"" }}"
|
||||
value="{{ magic_form.identity.value|default:"" }}"
|
||||
placeholder="you@example.org"
|
||||
autocomplete="email">
|
||||
|
||||
@ -56,8 +56,10 @@
|
||||
{% csrf_token %}
|
||||
<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 %}
|
||||
<p>
|
||||
Enter your <strong>email address</strong> and <strong>password</strong>.
|
||||
|
Loading…
x
Reference in New Issue
Block a user