|
|
@@ -1,35 +1,22 @@
|
|
|
+import json
|
|
|
import logging
|
|
|
-import socket
|
|
|
|
|
|
from datetime import timedelta
|
|
|
|
|
|
from django.conf import settings
|
|
|
+from django.contrib.auth import get_user_model
|
|
|
from django.contrib.auth import logout
|
|
|
-from django.core.exceptions import ObjectDoesNotExist
|
|
|
from django.http import HttpResponse
|
|
|
from django.http import HttpResponseRedirect
|
|
|
-from django.shortcuts import render_to_response
|
|
|
-from django.template import RequestContext
|
|
|
+from django.shortcuts import render
|
|
|
+from django.utils import six
|
|
|
from django.utils import timezone as datetime
|
|
|
-from django.utils.translation import ugettext_lazy
|
|
|
-
|
|
|
-try:
|
|
|
- from django.contrib.auth import get_user_model
|
|
|
-except ImportError: # django < 1.5
|
|
|
- from django.contrib.auth.models import User
|
|
|
-else:
|
|
|
- User = get_user_model()
|
|
|
-
|
|
|
-try:
|
|
|
- from django.contrib.auth.models import SiteProfileNotAvailable
|
|
|
-except ImportError: # django >= 1.7
|
|
|
- SiteProfileNotAvailable = type('SiteProfileNotAvailable', (Exception,), {})
|
|
|
|
|
|
-from axes.models import AccessLog
|
|
|
from axes.models import AccessAttempt
|
|
|
+from axes.models import AccessLog
|
|
|
from axes.signals import user_locked_out
|
|
|
+from axes.utils import iso8601
|
|
|
import axes
|
|
|
-from django.utils import six
|
|
|
|
|
|
|
|
|
# see if the user has overridden the failure limit
|
|
|
@@ -49,33 +36,36 @@ PASSWORD_FORM_FIELD = getattr(settings, 'AXES_PASSWORD_FORM_FIELD', 'password')
|
|
|
# see if the django app is sitting behind a reverse proxy
|
|
|
BEHIND_REVERSE_PROXY = getattr(settings, 'AXES_BEHIND_REVERSE_PROXY', False)
|
|
|
|
|
|
-# see if the django app is sitting behind a reverse proxy but can be accessed directly
|
|
|
-BEHIND_REVERSE_PROXY_WITH_DIRECT_ACCESS = getattr(settings, 'AXES_BEHIND_REVERSE_PROXY_WITH_DIRECT_ACCESS', False)
|
|
|
-
|
|
|
# if the django app is behind a reverse proxy, look for the ip address using this HTTP header value
|
|
|
-REVERSE_PROXY_HEADER = getattr(settings, 'AXES_REVERSE_PROXY_HEADER', 'HTTP_X_FORWARDED_FOR')
|
|
|
+REVERSE_PROXY_HEADER = \
|
|
|
+ getattr(settings, 'AXES_REVERSE_PROXY_HEADER', 'HTTP_X_FORWARDED_FOR')
|
|
|
|
|
|
# lock out user from particular IP based on combination USER+IP
|
|
|
-def should_lock_out_by_combination_user_and_ip():
|
|
|
- return getattr(settings, 'AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP', False)
|
|
|
+LOCK_OUT_BY_COMBINATION_USER_AND_IP = \
|
|
|
+ getattr(settings, 'AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP', False)
|
|
|
|
|
|
COOLOFF_TIME = getattr(settings, 'AXES_COOLOFF_TIME', None)
|
|
|
-if (isinstance(COOLOFF_TIME, int) or isinstance(COOLOFF_TIME, float) ):
|
|
|
+if (isinstance(COOLOFF_TIME, int) or isinstance(COOLOFF_TIME, float)):
|
|
|
COOLOFF_TIME = timedelta(hours=COOLOFF_TIME)
|
|
|
|
|
|
LOGGER = getattr(settings, 'AXES_LOGGER', 'axes.watch_login')
|
|
|
|
|
|
LOCKOUT_TEMPLATE = getattr(settings, 'AXES_LOCKOUT_TEMPLATE', None)
|
|
|
+
|
|
|
+LOCKOUT_URL = getattr(settings, 'AXES_LOCKOUT_URL', None)
|
|
|
+
|
|
|
VERBOSE = getattr(settings, 'AXES_VERBOSE', True)
|
|
|
|
|
|
# whitelist and blacklist
|
|
|
-# todo: convert the strings to IPv4 on startup to avoid type conversion during processing
|
|
|
+# TODO: convert the strings to IPv4 on startup to avoid type conversion during processing
|
|
|
+NEVER_LOCKOUT_WHITELIST = \
|
|
|
+ getattr(settings, 'AXES_NEVER_LOCKOUT_WHITELIST', False)
|
|
|
+
|
|
|
ONLY_WHITELIST = getattr(settings, 'AXES_ONLY_ALLOW_WHITELIST', False)
|
|
|
+
|
|
|
IP_WHITELIST = getattr(settings, 'AXES_IP_WHITELIST', None)
|
|
|
-IP_BLACKLIST = getattr(settings, 'AXES_IP_BLACKLIST', None)
|
|
|
|
|
|
-ERROR_MESSAGE = ugettext_lazy("Please enter a correct username and password. "
|
|
|
- "Note that both fields are case-sensitive.")
|
|
|
+IP_BLACKLIST = getattr(settings, 'AXES_IP_BLACKLIST', None)
|
|
|
|
|
|
|
|
|
log = logging.getLogger(LOGGER)
|
|
|
@@ -85,67 +75,25 @@ if VERBOSE:
|
|
|
|
|
|
|
|
|
if BEHIND_REVERSE_PROXY:
|
|
|
- log.debug('Axes is configured to be behind reverse proxy...looking for header value %s', REVERSE_PROXY_HEADER)
|
|
|
-
|
|
|
-
|
|
|
-def is_valid_ip(ip_address):
|
|
|
- """ Check Validity of an IP address """
|
|
|
- valid = True
|
|
|
- try:
|
|
|
- socket.inet_aton(ip_address.strip())
|
|
|
- except:
|
|
|
- valid = False
|
|
|
- return valid
|
|
|
-
|
|
|
-
|
|
|
-def get_ip_address_from_request(request):
|
|
|
- """ Makes the best attempt to get the client's real IP or return the loopback """
|
|
|
- PRIVATE_IPS_PREFIX = ('10.', '172.', '192.', '127.')
|
|
|
- ip_address = ''
|
|
|
- x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR', '')
|
|
|
- if x_forwarded_for and ',' not in x_forwarded_for:
|
|
|
- # Hue's LB can be hosted on Private IP address.
|
|
|
- if is_valid_ip(x_forwarded_for):
|
|
|
- ip_address = x_forwarded_for.strip()
|
|
|
- elif x_forwarded_for and ',' in x_forwarded_for:
|
|
|
- # Hue Server may have more than one reverse proxy in front of it.
|
|
|
- x_forwarded_for_first_ip = x_forwarded_for.split(',')[0]
|
|
|
- if is_valid_ip(x_forwarded_for_first_ip):
|
|
|
- ip_address = x_forwarded_for_first_ip.strip()
|
|
|
- else:
|
|
|
- ips = [ip.strip() for ip in x_forwarded_for.split(',')]
|
|
|
- for ip in ips:
|
|
|
- if ip.startswith(PRIVATE_IPS_PREFIX):
|
|
|
- continue
|
|
|
- elif not is_valid_ip(ip):
|
|
|
- continue
|
|
|
- else:
|
|
|
- ip_address = ip
|
|
|
- break
|
|
|
- if not ip_address:
|
|
|
- x_real_ip = request.META.get('HTTP_X_REAL_IP', '')
|
|
|
- if x_real_ip:
|
|
|
- if not x_real_ip.startswith(PRIVATE_IPS_PREFIX) and is_valid_ip(x_real_ip):
|
|
|
- ip_address = x_real_ip.strip()
|
|
|
- if not ip_address:
|
|
|
- remote_addr = request.META.get('REMOTE_ADDR', '')
|
|
|
- if remote_addr:
|
|
|
- if not remote_addr.startswith(PRIVATE_IPS_PREFIX) and is_valid_ip(remote_addr):
|
|
|
- ip_address = remote_addr.strip()
|
|
|
- if remote_addr.startswith(PRIVATE_IPS_PREFIX) and is_valid_ip(remote_addr):
|
|
|
- ip_address = remote_addr.strip()
|
|
|
- if not ip_address:
|
|
|
- ip_address = '127.0.0.1'
|
|
|
- return ip_address
|
|
|
+ log.debug('Axes is configured to be behind reverse proxy')
|
|
|
+ log.debug('Looking for header value %s', REVERSE_PROXY_HEADER)
|
|
|
|
|
|
|
|
|
def get_ip(request):
|
|
|
- ip = get_ip_address_from_request(request)
|
|
|
- return ip
|
|
|
-
|
|
|
+ ip = request.META.get('REMOTE_ADDR', '')
|
|
|
+
|
|
|
+ if BEHIND_REVERSE_PROXY:
|
|
|
+ ip = request.META.get(REVERSE_PROXY_HEADER, '').split(',', 1)[0]
|
|
|
+ ip = ip.strip()
|
|
|
+ if not ip:
|
|
|
+ raise Warning(
|
|
|
+ 'Axes is configured for operation behind a reverse proxy '
|
|
|
+ 'but could not find an HTTP header value. Check your proxy '
|
|
|
+ 'server settings to make sure this header value is being '
|
|
|
+ 'passed. Header value {0}'.format(REVERSE_PROXY_HEADER)
|
|
|
+ )
|
|
|
|
|
|
-def get_lockout_url():
|
|
|
- return getattr(settings, 'AXES_LOCKOUT_URL', None)
|
|
|
+ return ip
|
|
|
|
|
|
|
|
|
def query2str(items, max_length=1024):
|
|
|
@@ -153,15 +101,13 @@ def query2str(items, max_length=1024):
|
|
|
|
|
|
If there's a field called "password" it will be excluded from the output.
|
|
|
|
|
|
- The length of the output is limited to max_length to avoid a DoS attack.
|
|
|
+ The length of the output is limited to max_length to avoid a DoS attack
|
|
|
+ via excessively large payloads.
|
|
|
"""
|
|
|
-
|
|
|
- kvs = []
|
|
|
- for k, v in items:
|
|
|
- if k != PASSWORD_FORM_FIELD:
|
|
|
- kvs.append(six.u('%s=%s') % (k, v))
|
|
|
-
|
|
|
- return '\n'.join(kvs)[:max_length]
|
|
|
+ return '\n'.join([
|
|
|
+ '%s=%s' % (k, v) for k, v in six.iteritems(items)
|
|
|
+ if k != PASSWORD_FORM_FIELD
|
|
|
+ ][:int(max_length / 2)])[:max_length]
|
|
|
|
|
|
|
|
|
def ip_in_whitelist(ip):
|
|
|
@@ -184,41 +130,30 @@ def is_user_lockable(request):
|
|
|
and doesn't get their account locked out
|
|
|
"""
|
|
|
try:
|
|
|
- field = getattr(User, 'USERNAME_FIELD', 'username')
|
|
|
+ field = getattr(get_user_model(), 'USERNAME_FIELD', 'username')
|
|
|
kwargs = {
|
|
|
field: request.POST.get(USERNAME_FORM_FIELD)
|
|
|
}
|
|
|
- user = User.objects.get(**kwargs)
|
|
|
- except User.DoesNotExist:
|
|
|
+ user = get_user_model().objects.get(**kwargs)
|
|
|
+
|
|
|
+ if hasattr(user, 'nolockout'):
|
|
|
+ # need to invert since we need to return
|
|
|
+ # false for users that can't be blocked
|
|
|
+ return not user.nolockout
|
|
|
+
|
|
|
+ except get_user_model().DoesNotExist:
|
|
|
# not a valid user
|
|
|
return True
|
|
|
|
|
|
- if hasattr(user, 'nolockout'):
|
|
|
- # need to revert since we need to return
|
|
|
- # false for users that can't be blocked
|
|
|
- return not user.nolockout
|
|
|
-
|
|
|
- elif hasattr(settings, 'AUTH_PROFILE_MODULE'):
|
|
|
- try:
|
|
|
- profile = user.get_profile()
|
|
|
- if hasattr(profile, 'nolockout'):
|
|
|
- # need to revert since we need to return
|
|
|
- # false for users that can't be blocked
|
|
|
- return not profile.nolockout
|
|
|
-
|
|
|
- except (SiteProfileNotAvailable, ObjectDoesNotExist, AttributeError):
|
|
|
- # no profile
|
|
|
- return True
|
|
|
-
|
|
|
# Default behavior for a user to be lockable
|
|
|
return True
|
|
|
|
|
|
+
|
|
|
def _get_user_attempts(request):
|
|
|
"""Returns access attempt record if it exists.
|
|
|
Otherwise return None.
|
|
|
"""
|
|
|
ip = get_ip(request)
|
|
|
-
|
|
|
username = request.POST.get(USERNAME_FORM_FIELD, None)
|
|
|
|
|
|
if USE_USER_AGENT:
|
|
|
@@ -235,13 +170,14 @@ def _get_user_attempts(request):
|
|
|
params = {'ip_address': ip, 'trusted': False}
|
|
|
if USE_USER_AGENT:
|
|
|
params['user_agent'] = ua
|
|
|
- if should_lock_out_by_combination_user_and_ip():
|
|
|
+ if LOCK_OUT_BY_COMBINATION_USER_AND_IP:
|
|
|
params['username'] = username
|
|
|
|
|
|
attempts = AccessAttempt.objects.filter(**params)
|
|
|
|
|
|
return attempts
|
|
|
|
|
|
+
|
|
|
def get_user_attempts(request):
|
|
|
objects_deleted = False
|
|
|
attempts = _get_user_attempts(request)
|
|
|
@@ -269,6 +205,10 @@ def watch_login(func):
|
|
|
Used to decorate the django.contrib.admin.site.login method.
|
|
|
"""
|
|
|
|
|
|
+ # Don't decorate multiple times
|
|
|
+ if func.__name__ == 'decorated_login':
|
|
|
+ return func
|
|
|
+
|
|
|
def decorated_login(request, *args, **kwargs):
|
|
|
# share some useful information
|
|
|
if func.__name__ != 'decorated_login' and VERBOSE:
|
|
|
@@ -305,19 +245,21 @@ def watch_login(func):
|
|
|
|
|
|
if request.method == 'POST':
|
|
|
# see if the login was successful
|
|
|
-
|
|
|
login_unsuccessful = (
|
|
|
response and
|
|
|
not response.has_header('location') and
|
|
|
response.status_code != 302
|
|
|
)
|
|
|
|
|
|
- access_log = AccessLog.objects.create(
|
|
|
- user_agent=request.META.get('HTTP_USER_AGENT', '<unknown>')[:255],
|
|
|
+ user_agent = request.META.get('HTTP_USER_AGENT', '<unknown>')[:255]
|
|
|
+ http_accept = request.META.get('HTTP_ACCEPT', '<unknown>')[:1025]
|
|
|
+ path_info = request.META.get('PATH_INFO', '<unknown>')[:255]
|
|
|
+ AccessLog.objects.create(
|
|
|
+ user_agent=user_agent,
|
|
|
ip_address=get_ip(request),
|
|
|
username=request.POST.get(USERNAME_FORM_FIELD, None),
|
|
|
- http_accept=request.META.get('HTTP_ACCEPT', '<unknown>'),
|
|
|
- path_info=request.META.get('PATH_INFO', '<unknown>'),
|
|
|
+ http_accept=http_accept,
|
|
|
+ path_info=path_info,
|
|
|
trusted=not login_unsuccessful,
|
|
|
)
|
|
|
if check_request(request, login_unsuccessful):
|
|
|
@@ -331,41 +273,57 @@ def watch_login(func):
|
|
|
|
|
|
|
|
|
def lockout_response(request):
|
|
|
- if LOCKOUT_TEMPLATE:
|
|
|
- context = {
|
|
|
- 'cooloff_time': COOLOFF_TIME,
|
|
|
- 'failure_limit': FAILURE_LIMIT,
|
|
|
- 'username': request.POST.get(USERNAME_FORM_FIELD, '')
|
|
|
- }
|
|
|
- return render_to_response(LOCKOUT_TEMPLATE, context,
|
|
|
- context_instance=RequestContext(request))
|
|
|
+ context = {
|
|
|
+ 'failure_limit': FAILURE_LIMIT,
|
|
|
+ 'username': request.POST.get(USERNAME_FORM_FIELD, '')
|
|
|
+ }
|
|
|
+
|
|
|
+ if request.is_ajax():
|
|
|
+ if COOLOFF_TIME:
|
|
|
+ context.update({'cooloff_time': iso8601(COOLOFF_TIME)})
|
|
|
+
|
|
|
+ return HttpResponse(
|
|
|
+ json.dumps(context),
|
|
|
+ content_type='application/json',
|
|
|
+ status=403,
|
|
|
+ )
|
|
|
|
|
|
- LOCKOUT_URL = get_lockout_url()
|
|
|
- if LOCKOUT_URL:
|
|
|
+ elif LOCKOUT_TEMPLATE:
|
|
|
+ if COOLOFF_TIME:
|
|
|
+ context.update({'cooloff_time': iso8601(COOLOFF_TIME)})
|
|
|
+
|
|
|
+ return render(request, LOCKOUT_TEMPLATE, context, status=403)
|
|
|
+
|
|
|
+ elif LOCKOUT_URL:
|
|
|
return HttpResponseRedirect(LOCKOUT_URL)
|
|
|
|
|
|
- if COOLOFF_TIME:
|
|
|
- return HttpResponse("Account locked: too many login attempts. "
|
|
|
- "Please try again later.")
|
|
|
else:
|
|
|
- return HttpResponse("Account locked: too many login attempts. "
|
|
|
- "Contact an admin to unlock your account.")
|
|
|
+ msg = 'Account locked: too many login attempts. {0}'
|
|
|
+ if COOLOFF_TIME:
|
|
|
+ msg = msg.format('Please try again later.')
|
|
|
+ else:
|
|
|
+ msg = msg.format('Contact an admin to unlock your account.')
|
|
|
+
|
|
|
+ return HttpResponse(msg, status=403)
|
|
|
|
|
|
|
|
|
def is_already_locked(request):
|
|
|
ip = get_ip(request)
|
|
|
|
|
|
- if ONLY_WHITELIST:
|
|
|
- if not ip_in_whitelist(ip):
|
|
|
- return True
|
|
|
+ if NEVER_LOCKOUT_WHITELIST and ip_in_whitelist(ip):
|
|
|
+ return False
|
|
|
+
|
|
|
+ if ONLY_WHITELIST and not ip_in_whitelist(ip):
|
|
|
+ return True
|
|
|
|
|
|
if ip_in_blacklist(ip):
|
|
|
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 and user_lockable:
|
|
|
+ if not is_user_lockable(request):
|
|
|
+ return False
|
|
|
+
|
|
|
+ for attempt in get_user_attempts(request):
|
|
|
+ if attempt.failures_since_start >= FAILURE_LIMIT and LOCK_OUT_AT_FAILURE:
|
|
|
return True
|
|
|
|
|
|
return False
|
|
|
@@ -390,20 +348,23 @@ def check_request(request, login_unsuccessful):
|
|
|
for attempt in attempts:
|
|
|
attempt.get_data = '%s\n---------\n%s' % (
|
|
|
attempt.get_data,
|
|
|
- query2str(request.GET.items()),
|
|
|
+ query2str(request.GET),
|
|
|
)
|
|
|
attempt.post_data = '%s\n---------\n%s' % (
|
|
|
attempt.post_data,
|
|
|
- query2str(request.POST.items())
|
|
|
+ query2str(request.POST)
|
|
|
)
|
|
|
- attempt.http_accept = request.META.get('HTTP_ACCEPT', '<unknown>')
|
|
|
- attempt.path_info = request.META.get('PATH_INFO', '<unknown>')
|
|
|
+ attempt.http_accept = \
|
|
|
+ request.META.get('HTTP_ACCEPT', '<unknown>')[:1025]
|
|
|
+ attempt.path_info = \
|
|
|
+ request.META.get('PATH_INFO', '<unknown>')[:255]
|
|
|
attempt.failures_since_start = failures
|
|
|
attempt.attempt_time = datetime.now()
|
|
|
attempt.save()
|
|
|
- log.info('AXES: Repeated login failure by %s. Updating access '
|
|
|
- 'record. Count = %s' %
|
|
|
- (attempt.ip_address, failures))
|
|
|
+ log.info(
|
|
|
+ 'AXES: Repeated login failure by %s. Updating access '
|
|
|
+ 'record. Count = %s' % (attempt.ip_address, failures)
|
|
|
+ )
|
|
|
else:
|
|
|
create_new_failure_records(request, failures)
|
|
|
else:
|
|
|
@@ -421,6 +382,9 @@ def check_request(request, login_unsuccessful):
|
|
|
if trusted_record_exists is False:
|
|
|
create_new_trusted_record(request)
|
|
|
|
|
|
+ if NEVER_LOCKOUT_WHITELIST and ip_in_whitelist(ip_address):
|
|
|
+ return True
|
|
|
+
|
|
|
user_lockable = is_user_lockable(request)
|
|
|
# no matter what, we want to lock them out if they're past the number of
|
|
|
# attempts allowed, unless the user is set to notlockable
|
|
|
@@ -429,10 +393,13 @@ def check_request(request, login_unsuccessful):
|
|
|
# password
|
|
|
if hasattr(request, 'user') and request.user.is_authenticated():
|
|
|
logout(request)
|
|
|
- log.warn('AXES: locked out %s after repeated login attempts.' %
|
|
|
- (ip_address,))
|
|
|
+ log.warn(
|
|
|
+ 'AXES: locked out %s after repeated login attempts.' % ip_address
|
|
|
+ )
|
|
|
# send signal when someone is locked out.
|
|
|
- user_locked_out.send("axes", 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]:
|
|
|
@@ -449,18 +416,16 @@ def create_new_failure_records(request, failures):
|
|
|
ua = request.META.get('HTTP_USER_AGENT', '<unknown>')[:255]
|
|
|
username = request.POST.get(USERNAME_FORM_FIELD, None)
|
|
|
|
|
|
- params = {
|
|
|
- 'user_agent': ua,
|
|
|
- 'ip_address': ip,
|
|
|
- 'username': username,
|
|
|
- 'get_data': query2str(request.GET.items()),
|
|
|
- 'post_data': query2str(request.POST.items()),
|
|
|
- 'http_accept': request.META.get('HTTP_ACCEPT', '<unknown>'),
|
|
|
- 'path_info': request.META.get('PATH_INFO', '<unknown>'),
|
|
|
- 'failures_since_start': failures,
|
|
|
- }
|
|
|
-
|
|
|
- AccessAttempt.objects.create(**params)
|
|
|
+ AccessAttempt.objects.create(
|
|
|
+ user_agent=ua,
|
|
|
+ ip_address=ip,
|
|
|
+ username=username,
|
|
|
+ get_data=query2str(request.GET),
|
|
|
+ post_data=query2str(request.POST),
|
|
|
+ http_accept=request.META.get('HTTP_ACCEPT', '<unknown>'),
|
|
|
+ path_info=request.META.get('PATH_INFO', '<unknown>'),
|
|
|
+ failures_since_start=failures,
|
|
|
+ )
|
|
|
|
|
|
log.info('AXES: New login failure by %s. Creating access record.' % (ip,))
|
|
|
|
|
|
@@ -477,8 +442,8 @@ def create_new_trusted_record(request):
|
|
|
user_agent=ua,
|
|
|
ip_address=ip,
|
|
|
username=username,
|
|
|
- get_data=query2str(request.GET.items()),
|
|
|
- post_data=query2str(request.POST.items()),
|
|
|
+ get_data=query2str(request.GET),
|
|
|
+ post_data=query2str(request.POST),
|
|
|
http_accept=request.META.get('HTTP_ACCEPT', '<unknown>'),
|
|
|
path_info=request.META.get('PATH_INFO', '<unknown>'),
|
|
|
failures_since_start=0,
|