From 9682a395281bf89557405c10dfeccfc0ff4a3c16 Mon Sep 17 00:00:00 2001 From: Ken Cochrane Date: Sun, 25 Nov 2012 15:46:45 -0500 Subject: [PATCH 1/5] Added AccessLog model to log all access attempts. refactored the models so there is a common abstract base class. Also added the model to django admin --- axes/admin.py | 19 ++++++++++++++++++- axes/decorators.py | 15 ++++++++++++++- axes/models.py | 22 +++++++++++++++------- 3 files changed, 47 insertions(+), 9 deletions(-) diff --git a/axes/admin.py b/axes/admin.py index c832258..8ab2218 100644 --- a/axes/admin.py +++ b/axes/admin.py @@ -1,5 +1,5 @@ from django.contrib import admin -from axes.models import AccessAttempt +from axes.models import AccessAttempt, AccessLog class AccessAttemptAdmin(admin.ModelAdmin): @@ -21,3 +21,20 @@ class AccessAttemptAdmin(admin.ModelAdmin): ) admin.site.register(AccessAttempt, AccessAttemptAdmin) + +class AccessLogAdmin(admin.ModelAdmin): + list_display = ('attempt_time','logout_time', 'ip_address', + 'user_agent', 'path_info') + list_filter = ['attempt_time', 'logout_time', 'ip_address', 'path_info'] + search_fields = ['ip_address', 'user_agent', 'path_info'] + date_hierarchy = 'attempt_time' + fieldsets = ( + (None, { + 'fields': ('path_info',) + }), + ('Meta Data', { + 'fields': ('user_agent', 'ip_address', 'http_accept') + }) + ) + +admin.site.register(AccessLog, AccessLogAdmin) \ No newline at end of file diff --git a/axes/decorators.py b/axes/decorators.py index fa81a21..9ab2768 100644 --- a/axes/decorators.py +++ b/axes/decorators.py @@ -23,7 +23,7 @@ except ImportError: # Fallback for Django < 1.4. from datetime import datetime -from axes.models import AccessAttempt +from axes.models import AccessAttempt, AccessLog import axes # see if the user has overridden the failure limit @@ -225,7 +225,20 @@ def is_already_locked(request): return False +def log_access_request(request, login_unsuccessful): + """ Log the access attempt """ + access_log = AccessLog() + access_log.user_agent = request.META.get('HTTP_USER_AGENT', '') + access_log.ip_address = request.META.get('REMOTE_ADDR', '') + access_log.username = request.POST.get('username', None) + access_log.http_accept = request.META.get('HTTP_ACCEPT', '') + access_log.path_info = request.META.get('PATH_INFO', '') + access_log.trusted = login_unsuccessful + access_log.save() + + def check_request(request, login_unsuccessful): + log_access_request(request, login_unsuccessful) failures = 0 attempts = get_user_attempts(request) diff --git a/axes/models.py b/axes/models.py index 1c3c5e3..7fc3aaf 100644 --- a/axes/models.py +++ b/axes/models.py @@ -6,8 +6,7 @@ FAILURES_DESC = 'Failed Logins' # set unique by user_agent, ip # make user agent, ip indexed fields - -class AccessAttempt(models.Model): +class CommonAccess(models.Model): user_agent = models.CharField(max_length=255) ip_address = models.IPAddressField('IP Address', null=True) username = models.CharField(max_length=255, null=True) @@ -15,13 +14,19 @@ class AccessAttempt(models.Model): # Once a user logs in from an ip, that combination is trusted and not # locked out in case of a distributed attack trusted = models.BooleanField(default=False) - get_data = models.TextField('GET Data') - post_data = models.TextField('POST Data') http_accept = models.CharField('HTTP Accept', max_length=255) path_info = models.CharField('Path', max_length=255) - failures_since_start = models.PositiveIntegerField(FAILURES_DESC) attempt_time = models.DateTimeField(auto_now_add=True) + class Meta: + abstract = True + ordering = ['-attempt_time'] + +class AccessAttempt(CommonAccess): + get_data = models.TextField('GET Data') + post_data = models.TextField('POST Data') + failures_since_start = models.PositiveIntegerField(FAILURES_DESC) + def __unicode__(self): return u'Attempted Access: %s' % self.attempt_time @@ -29,5 +34,8 @@ class AccessAttempt(models.Model): def failures(self): return self.failures_since_start - class Meta: - ordering = ['-attempt_time'] +class AccessLog(CommonAccess): + logout_time = models.DateTimeField(null=True, blank=True) + + def __unicode__(self): + return u'Access Log for %s @ %s' % (self.user, self.attempt_time) \ No newline at end of file From 2b776733365bd84fea9c601ea2d3b69a1f15f9e1 Mon Sep 17 00:00:00 2001 From: Ken Cochrane Date: Sun, 25 Nov 2012 17:42:55 -0500 Subject: [PATCH 2/5] added a signal for when a user gets locked out --- axes/decorators.py | 7 ++++++- axes/signals.py | 3 +++ 2 files changed, 9 insertions(+), 1 deletion(-) create mode 100644 axes/signals.py diff --git a/axes/decorators.py b/axes/decorators.py index 9ab2768..c97199d 100644 --- a/axes/decorators.py +++ b/axes/decorators.py @@ -24,6 +24,7 @@ except ImportError: from datetime import datetime from axes.models import AccessAttempt, AccessLog +from axes.signals import user_locked_out import axes # see if the user has overridden the failure limit @@ -239,6 +240,8 @@ def log_access_request(request, login_unsuccessful): def check_request(request, login_unsuccessful): log_access_request(request, login_unsuccessful) + ip_address = request.META.get('REMOTE_ADDR', '') + username = request.POST.get('username', None) failures = 0 attempts = get_user_attempts(request) @@ -293,7 +296,9 @@ def check_request(request, login_unsuccessful): # password logout(request) log.warn('AXES: locked out %s after repeated login attempts.' % - (attempt.ip_address,)) + (ip_address,)) + # send signal when someone is locked out. + user_locked_out.send(request=request, username=username) # if a trusted login has violated lockout, revoke trust for attempt in [a for a in attempts if a.trusted]: diff --git a/axes/signals.py b/axes/signals.py new file mode 100644 index 0000000..81fb9db --- /dev/null +++ b/axes/signals.py @@ -0,0 +1,3 @@ +from django.dispatch import Signal + +user_locked_out = Signal(providing_args=['request', 'username']) \ No newline at end of file From 4e16a85aedbda2c3b629b97891280d111d759220 Mon Sep 17 00:00:00 2001 From: Ken Cochrane Date: Sun, 25 Nov 2012 18:20:56 -0500 Subject: [PATCH 3/5] added ipaddress as a param to the user_locked_out signal; also added a signal reciever for user_logged_out so that we can log when the user logs out in the accessLog table. --- axes/decorators.py | 3 ++- axes/signals.py | 28 ++++++++++++++++++++++++++-- 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/axes/decorators.py b/axes/decorators.py index c97199d..2ab3c01 100644 --- a/axes/decorators.py +++ b/axes/decorators.py @@ -298,7 +298,8 @@ def check_request(request, login_unsuccessful): log.warn('AXES: locked out %s after repeated login attempts.' % (ip_address,)) # send signal when someone is locked out. - user_locked_out.send(request=request, username=username) + user_locked_out.send(request=request, username=username, + ip_address=ip_address) # if a trusted login has violated lockout, revoke trust for attempt in [a for a in attempts if a.trusted]: diff --git a/axes/signals.py b/axes/signals.py index 81fb9db..7878961 100644 --- a/axes/signals.py +++ b/axes/signals.py @@ -1,3 +1,27 @@ -from django.dispatch import Signal +from django.dispatch import Signal, receiver +from django.contrib.auth.signals import user_logged_out +from django.core.exceptions import ObjectDoesNotExist +from axes.models import AccessLog -user_locked_out = Signal(providing_args=['request', 'username']) \ No newline at end of file +# django 1.4 has a new timezone aware now() use if available. +try: + from django.utils.timezone import now +except ImportError: + # fall back to none timezone aware now() + from datetime import datetime + now = datetime.now + +user_locked_out = Signal(providing_args=['request', 'username', 'ip_address']) + +@receiver(user_logged_out) +def log_user_lockout(sender, request, user, signal, *args, **kwargs): + """ When a user logs out, update the access log""" + if not user: + return + + access_log = AccessLog.objects.filter(username=user.username, + logout_time__isnull=True).order_by("-attempt_time")[0] + + if access_log: + access_log.logout_time = now() + access_log.save() \ No newline at end of file From 82296db695bfdd5d5b2e241cc68440a10bba46af Mon Sep 17 00:00:00 2001 From: Ken Cochrane Date: Sun, 25 Nov 2012 18:39:14 -0500 Subject: [PATCH 4/5] Added ability to flag user accounts as unlockable, by having a field in the UserProfile called nolockout and set to True, if not there it is ignored --- axes/decorators.py | 27 +++++++++++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/axes/decorators.py b/axes/decorators.py index 2ab3c01..35c76cc 100644 --- a/axes/decorators.py +++ b/axes/decorators.py @@ -92,6 +92,28 @@ if VERBOSE: log.info('AXES: BEGIN LOG') log.info('Using django-axes ' + axes.get_version()) +def is_user_lockable(request): + """ Check if the user has a profile with nolockout + If so, then return the value to see if this user is special + and doesn't get their account locked out """ + username = request.POST.get('username', None) + try: + user = User.objects.get(username=username) + except User.DoesNotExist: + # not a valid user + return True + try: + profile = user.get_profile() + except: + # no profile + return True + + if hasattr(profile, 'nolockout'): + # need to revert since we need to return + # false for users that can't be blocked + return not profile.nolockout + else: + return True def get_user_attempts(request): """ @@ -289,9 +311,10 @@ def check_request(request, login_unsuccessful): if trusted_record_exists is False: create_new_trusted_record(request) + user_lockable = is_user_lockable(request) # no matter what, we want to lock them out if they're past the number of - # attempts allowed - if failures >= FAILURE_LIMIT and LOCK_OUT_AT_FAILURE: + # attempts allowed, unless the user is set to notlockable + if failures >= FAILURE_LIMIT and LOCK_OUT_AT_FAILURE and user_lockable: # We log them out in case they actually managed to enter the correct # password logout(request) From 3b58e9bc16a108c498c9926cd94763babddb55f5 Mon Sep 17 00:00:00 2001 From: Ken Cochrane Date: Sun, 25 Nov 2012 19:41:55 -0500 Subject: [PATCH 5/5] fixed a couple of issues from previous commits, imported signals in models to load them correctly. --- axes/decorators.py | 11 +++++------ axes/models.py | 2 +- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/axes/decorators.py b/axes/decorators.py index 35c76cc..925c465 100644 --- a/axes/decorators.py +++ b/axes/decorators.py @@ -120,7 +120,6 @@ def get_user_attempts(request): Returns access attempt record if it exists. Otherwise return None. """ - ip = request.META.get('REMOTE_ADDR', '') username = request.POST.get('username', None) @@ -200,6 +199,7 @@ def watch_login(func): not response.has_header('location') and response.status_code != 302 ) + log_access_request(request, login_unsuccessful) if check_request(request, login_unsuccessful): return response @@ -241,8 +241,9 @@ def is_already_locked(request): return True attempts = get_user_attempts(request) + user_lockable = is_user_lockable(request) for attempt in attempts: - if attempt.failures_since_start >= FAILURE_LIMIT and LOCK_OUT_AT_FAILURE: + if attempt.failures_since_start >= FAILURE_LIMIT and LOCK_OUT_AT_FAILURE and user_lockable: return True return False @@ -256,12 +257,11 @@ def log_access_request(request, login_unsuccessful): access_log.username = request.POST.get('username', None) access_log.http_accept = request.META.get('HTTP_ACCEPT', '') access_log.path_info = request.META.get('PATH_INFO', '') - access_log.trusted = login_unsuccessful + access_log.trusted = not login_unsuccessful access_log.save() def check_request(request, login_unsuccessful): - log_access_request(request, login_unsuccessful) ip_address = request.META.get('REMOTE_ADDR', '') username = request.POST.get('username', None) failures = 0 @@ -321,8 +321,7 @@ def check_request(request, login_unsuccessful): log.warn('AXES: locked out %s after repeated login attempts.' % (ip_address,)) # send signal when someone is locked out. - user_locked_out.send(request=request, username=username, - ip_address=ip_address) + user_locked_out.send("axes", request=request, username=username, ip_address=ip_address) # if a trusted login has violated lockout, revoke trust for attempt in [a for a in attempts if a.trusted]: diff --git a/axes/models.py b/axes/models.py index 7fc3aaf..e31fd49 100644 --- a/axes/models.py +++ b/axes/models.py @@ -1,5 +1,5 @@ from django.db import models - +import signals FAILURES_DESC = 'Failed Logins' #XXX TODO