Skip to content

Commit dd10169

Browse files
authored
Merge pull request #39 from HackAssistant/login_tries_block
Login tries block
2 parents 7f5a35b + 1e980e0 commit dd10169

File tree

8 files changed

+115
-44
lines changed

8 files changed

+115
-44
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
- Email sign up ✉️
1212
- Email verification 📨
1313
- Forgot password 🤔
14+
- Ip block on failed login tries & ip blocklist ✋ (Optional)
1415
- Dark mode 🌚 🌝 Light mode (Optional)
1516

1617
## Development

app/settings.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
from pathlib import Path
1414

1515
from django.contrib.messages import constants as message_constants
16+
from django.utils import timezone
1617

1718
from .hackathon_variables import *
1819

@@ -60,6 +61,7 @@
6061
'application',
6162
'review',
6263
'event',
64+
'axes',
6365
]
6466

6567
MIDDLEWARE = [
@@ -129,6 +131,22 @@
129131
},
130132
]
131133

134+
AUTHENTICATION_BACKENDS = [
135+
# AxesStandaloneBackend should be the first backend in the AUTHENTICATION_BACKENDS list.
136+
'axes.backends.AxesStandaloneBackend',
137+
138+
# Django ModelBackend is the default authentication backend.
139+
'django.contrib.auth.backends.ModelBackend',
140+
]
141+
142+
# django-axes configuration
143+
AXES_USERNAME_FORM_FIELD = 'user.models.User.USERNAME_FIELD'
144+
AXES_COOLOFF_TIME = timezone.timedelta(minutes=5)
145+
AXES_FAILURE_LIMIT = os.environ.get('AXES_FAILURE_LIMIT', 3)
146+
AXES_ENABLED = os.environ.get('AXES_ENABLED', not DEBUG)
147+
AXES_IP_BLACKLIST = os.environ.get('AXES_IP_BLACKLIST', '').split(',')
148+
SILENCED_SYSTEM_CHECKS = ['axes.W002']
149+
132150

133151
# Internationalization
134152
# https://docs.djangoproject.com/en/4.0/topics/i18n/

requirements.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,14 @@ cryptography==37.0.2
88
Deprecated==1.2.13
99
Django==4.0.7
1010
django-appconf==1.0.5
11+
django-axes==5.39.0
1112
django-bootstrap5==21.3
1213
django-colorfield==0.7.2
1314
django-compressor==4.1
1415
django-cors-headers==3.13.0
1516
django-crontab==0.7.1
1617
django-filter==22.1
18+
django-ipware==4.0.2
1719
django-jwt-oidc==0.3.9
1820
django-libsass==0.9
1921
django-tables2==2.4.1

user/admin.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
from django.contrib.auth.models import Group
44

55
from user.forms import UserChangeForm, UserCreationForm
6-
from user.models import User, BlockedUser, LoginRequest
6+
from user.models import User, BlockedUser
77

88

99
class UserAdmin(BaseUserAdmin):
@@ -45,5 +45,4 @@ class GroupAdmin(BaseGroupAdmin):
4545
admin.site.register(User, UserAdmin)
4646
admin.site.unregister(Group)
4747
admin.site.register(Group, GroupAdmin)
48-
admin.site.register(LoginRequest)
4948
admin.site.register(BlockedUser)
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
# Generated by Django 4.0.7 on 2022-10-01 14:04
2+
3+
from django.db import migrations
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
('user', '0005_user_qr_code_alter_user_diet'),
10+
]
11+
12+
operations = [
13+
migrations.DeleteModel(
14+
name='LoginRequest',
15+
),
16+
]

user/models.py

Lines changed: 0 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -222,18 +222,6 @@ def get_users_with_permissions(cls, perms):
222222
Q(is_superuser=True)).distinct()
223223

224224

225-
class LoginRequest(models.Model):
226-
ip = models.CharField(max_length=30)
227-
latest_request = models.DateTimeField()
228-
login_tries = models.IntegerField(default=1)
229-
230-
def __str__(self):
231-
return self.ip
232-
233-
def reset_tries(self):
234-
self.login_tries = 1
235-
236-
237225
class BlockedUser(models.Model):
238226
full_name = models.CharField(max_length=100)
239227
email = models.EmailField(max_length=100)

user/templates/auth.html

Lines changed: 25 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -8,25 +8,35 @@
88
{% include 'components/tabs.html' %}
99
{% endif %}
1010
<div class="bg-{{ theme }} text-{% if theme == 'dark' %}white{% else %}black{% endif %}">
11-
<form method="post">
12-
{% csrf_token %}
13-
<div class="content p-4">
14-
{% include 'components/bootstrap5_form.html' %}
15-
{% if auth == 'register' %}
16-
{% if captcha_site_key %}
17-
<script src='https://www.google.com/recaptcha/api.js'></script>
18-
<div class="g-recaptcha" data-sitekey="{{ captcha_site_key }}"></div>
11+
{% if not blocked_message %}
12+
<form method="post">
13+
{% csrf_token %}
14+
<div class="content p-4">
15+
{% include 'components/bootstrap5_form.html' %}
16+
{% if auth == 'register' %}
17+
{% if captcha_site_key %}
18+
<script src='https://www.google.com/recaptcha/api.js'></script>
19+
<div class="g-recaptcha" data-sitekey="{{ captcha_site_key }}"></div>
20+
{% endif %}
21+
{% else %}
22+
<p><a class="text-{% if theme == 'dark' %}white{% else %}black{% endif %}" style="text-decoration: none" href="{% url 'forgot_password' %}">{% translate 'Forgot your password?' %}</a></p>
1923
{% endif %}
20-
{% else %}
21-
<p><a class="text-{% if theme == 'dark' %}white{% else %}black{% endif %}" style="text-decoration: none" href="{% url 'forgot_password' %}">{% translate 'Forgot your password?' %}</a></p>
22-
{% endif %}
23-
<div class="row justify-content-around">
24-
<div class="col-12 col-lg-6 d-grid d-md-block">
25-
<button type="submit" class="btn btn-primary col-12">{{ auth|title }}</button>
24+
<div class="row justify-content-around">
25+
<div class="col-12 col-lg-6 d-grid d-md-block">
26+
<button type="submit" class="btn btn-primary col-12">{{ auth|title }}</button>
27+
</div>
2628
</div>
2729
</div>
30+
</form>
31+
{% else %}
32+
<div class="p-3">
33+
<div class="alert alert-danger alert-dismissible fade show" role="alert">
34+
{{ blocked_message }}
35+
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
36+
</div>
2837
</div>
29-
</form>
38+
39+
{% endif %}
3040
</div>
3141
</div>
3242
</div>

user/views.py

Lines changed: 52 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
1+
from axes.handlers.proxy import AxesProxyHandler
2+
from axes.helpers import get_client_ip_address, get_cool_off
3+
from axes.models import AccessAttempt
4+
from axes.utils import reset_request
15
from django.contrib import auth, messages
26
from django.shortcuts import redirect
3-
from django.urls import reverse
7+
from django.urls import reverse, resolve
8+
from django.utils import timezone
49
from django.views import View
510
from django.views.generic import TemplateView
611
from django.utils.translation import gettext as _
@@ -12,11 +17,21 @@
1217
from user.mixins import LoginRequiredMixin, EmailNotVerifiedMixin
1318
from user.models import User
1419
from user.tokens import AccountActivationTokenGenerator
15-
from user.verification import check_client_ip, reset_tries, check_recaptcha
1620

1721

18-
class Login(TabsViewMixin, TemplateView):
22+
class AuthTemplateViews(TabsViewMixin, TemplateView):
1923
template_name = 'auth.html'
24+
names = {
25+
'login': 'log in',
26+
'register': 'register',
27+
}
28+
forms = {
29+
'login': LoginForm,
30+
'register': RegistrationForm,
31+
}
32+
33+
def get_current_tabs(self, **kwargs):
34+
return [('Log in', reverse('login')), ('Register', reverse('register'))]
2035

2136
def redirect_successful(self):
2237
next_ = self.request.GET.get('next', reverse('home'))
@@ -29,31 +44,54 @@ def get(self, request, *args, **kwargs):
2944
return self.redirect_successful()
3045
return super().get(request, *args, **kwargs)
3146

32-
def get_current_tabs(self, **kwargs):
33-
return [('Log in', reverse('login')), ('Register', reverse('register'))]
47+
@property
48+
def get_url_name(self):
49+
return resolve(self.request.path_info).url_name
50+
51+
def get_form_class(self):
52+
return self.forms.get(self.get_url_name)
53+
54+
def get_form(self):
55+
form_class = self.get_form_class()
56+
return form_class()
57+
58+
def get_context_data(self, **kwargs):
59+
context = super().get_context_data(**kwargs)
60+
context.update({'form': self.get_form(), 'auth': self.names.get(self.get_url_name, 'register')})
61+
return context
62+
63+
64+
class Login(AuthTemplateViews):
65+
def add_axes_context(self, context):
66+
if not AxesProxyHandler.is_allowed(self.request):
67+
ip_address = get_client_ip_address(self.request)
68+
attempt = AccessAttempt.objects.get(ip_address=ip_address)
69+
time_left = (attempt.attempt_time + get_cool_off()) - timezone.now()
70+
minutes_left = int((time_left.total_seconds() + 59) // 60)
71+
axes_error_message = _('Too many login attempts. Please try again in %s minutes.') % minutes_left
72+
context.update({'blocked_message': axes_error_message})
3473

3574
def get_context_data(self, **kwargs):
3675
context = super().get_context_data(**kwargs)
37-
context.update({'form': LoginForm(), 'auth': 'log in'})
76+
self.add_axes_context(context)
3877
return context
3978

40-
@check_client_ip
4179
def post(self, request, **kwargs):
4280
form = LoginForm(request.POST)
43-
if form.is_valid() and request.client_req_is_valid:
81+
context = self.get_context_data(**kwargs)
82+
if form.is_valid():
4483
email = form.cleaned_data['email']
4584
password = form.cleaned_data['password']
46-
user = auth.authenticate(email=email, password=password)
85+
user = auth.authenticate(email=email, password=password, request=request)
4786
if user and user.is_active:
4887
auth.login(request, user)
49-
reset_tries(request)
88+
reset_request(request)
5089
messages.success(request, _('Successfully logged in!'))
5190
return self.redirect_successful()
91+
elif getattr(request, 'axes_locked_out', False):
92+
return redirect(reverse('login'))
5293
else:
5394
form.add_error(None, _('Incorrect username or password. Please try again.'))
54-
if not request.client_req_is_valid:
55-
form.add_error(None, _('Too many login attempts. Please try again in 5 minutes.'))
56-
context = self.get_context_data(**kwargs)
5795
form.reset_status_fields()
5896
context.update({'form': form})
5997
return self.render_to_response(context)
@@ -62,10 +100,9 @@ def post(self, request, **kwargs):
62100
class Register(Login):
63101
def get_context_data(self, **kwargs):
64102
context = super().get_context_data(**kwargs)
65-
context.update({'form': RegistrationForm(), 'auth': 'register'})
103+
context.pop("blocked_message", None)
66104
return context
67105

68-
@check_recaptcha
69106
def post(self, request, **kwargs):
70107
form = RegistrationForm(request.POST)
71108
if form.is_valid() and request.recaptcha_is_valid:

0 commit comments

Comments
 (0)