Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

use an authentication backend to check if user is locked out. #315

Merged
merged 5 commits into from Apr 11, 2018

Conversation

markddavidoff
Copy link
Contributor

@markddavidoff markddavidoff commented Mar 13, 2018

@camilonova First Draft for input.

USAGE

settings.py

AUTHENTICATION_BACKENDS = [
    'axes.middleware.DjangoAxesAuthBackend',
    'django.contrib.auth.backends.ModelBackend',
]

login view call example

user = User.objects.get(email=form.cleaned_data['email'])
response_context = {}
user = authenticate(
    request=request,
    username=user.username,
    password=form.cleaned_data['password'],
    response_context=response_context
)
...
if 'error' not in context:
    # if don't have a more specific error, use a generic one.
    context['error'] = 'Incorrect Login Information'
render(request, 'log_in.html', context)

from django.contrib.auth.backends import ModelBackend
from django.core.exceptions import PermissionDenied

class DjangoAxesAuthBackend(ModelBackend):
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Name suggestions?

if is_already_locked(request):
# locked out, don't try to authenticate, just update return_context and return
error_msg = get_lockout_message()
response_context = kwargs.get('response_context', {})
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Its a bit weird to pass a context and expect a response value but its nice to get a "why" back.

@camilonova
Copy link
Member

Looks good so far, please keep going.

# locked out, don't try to authenticate, just update return_context and return
error_msg = get_lockout_message()
response_context = kwargs.get('response_context', {})
response_context['error'] = error_msg
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this mutate kwargs contents?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It expects a dict called 'response_context' to be passed as a kwarg and mutates that if it is present.

I agree its a bit weird to pass a context and expect a value set but its nice to get a "why" back and I couldn't figure out a better way to do this that still complies with the django auth backend structure.

axes/conf.py Outdated
AXES_COOLOFF_MESSAGE = 'Account locked: too many login attempts. Please try again later'

# message to show when locked out and have cooloff disabled
AXES_PERMALOCK_MESSAGE= 'Account locked: too many login attempts. Contact an admin to unlock your account.'
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing a newline.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

also missing a space on the =

if settings.AXES_COOLOFF_TIME:
return settings.AXES_COOLOFF_MESSAGE
else:
return settings.AXES_PERMALOCK_MESSAGE
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing a newline.

from django.core.exceptions import PermissionDenied


class DjangoAxesAuthBackend(ModelBackend):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe AxesModelBackend would be a neater name?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sure! I copy pasted a bunch of this from my subclass implementation and rushed to redact some of the naming and messages

from axes.attempts import is_already_locked
from axes.utils import get_lockout_message
from django.contrib.auth.backends import ModelBackend
from django.core.exceptions import PermissionDenied
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A more traditional import order would be to import Python core packages first, then Django packages and then custom packages. Something like

from django import ...

from axes import ...

@@ -0,0 +1,30 @@
from axes.attempts import is_already_locked
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This file is not middleware - it should be backends.py so the import for this module would be something like axes.backends.AxesModelBackend.

@aleksihakli
Copy link
Member

Looks good, the naming is a bit conflicting as this patchset implements an authentication backend instead of middleware (though it's easy to see it as middlewareish in functionality).

The approach, however, is very nice. An authentication backend denying logins is MUCH more neater and universal than monkey patching view dispatchers which is the current functionality.

If you could clean this up slightly and implement unit tests for the backend it would be nice to get this merged 👍

@camilonova
Copy link
Member

@markddavidoff can we have this soon? Looks amazing.

@markddavidoff
Copy link
Contributor Author

markddavidoff commented Mar 21, 2018 via email

@markddavidoff markddavidoff changed the title add a middleware to check if user is locked out. use an authentication backend to check if user is locked out. Apr 6, 2018
axes/backends.py Outdated
@@ -21,6 +21,9 @@ def authenticate(self, request, username=None, password=None, **kwargs):
:return: Nothing, but will update return_context with lockout message if user is locked out.
"""

if request is None:
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@camilonova @aleksihakli addressed pr concerns, but i have another query as to handle when request is None.

Django Rest Framework (which is very widely used) does not set request when passing it to authenticate, so things like @authentication_classes((BasicAuthentication,)) will error out.

options:

  • Error out like this
  • no-op if no request and log a warning

But i think we need to address this as just a no-op is a way that axes could not be called due to programmer error (and i think security type things should be on-by-default).

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this behaviour is fine for the time being. If someone wants to use this custom backend in their project they need to make sure it can function correctly.

@aleksihakli
Copy link
Member

Looks good to me! I think this is ready for merging. I'll let @camilonova check and merge this :)

@markddavidoff
Copy link
Contributor Author

moved the exception to a subclass and updated the documentation as well

Copy link
Member

@aleksihakli aleksihakli left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A few small cleanups could be used here :)

The RequestParameterRequired exception could maybe subclass the Django SuspiciousOperation exception?

This looks good for merging, though.

axes/backends.py Outdated
@@ -7,6 +7,13 @@

class AxesModelBackend(ModelBackend):

class RequestParameterRequired(Exception):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This exception should really live in axes.exceptions as per good naming conventions. You just create that exceptions.py file. There is no need to put this inside the AxesModelBackend class, nesting classes just makes this harder to read and find.

- make sure any auth libraries you use that call the authentication middleware stack pass request. Notably Django Rest
Framework (DRF) ``BasicAuthentication`` does not pass request. `Here is an example workaround for DRF`_.

.. _Here is an example workaround for DRF: http://www.python.org
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This doesn't seem like the right link.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Woops. Will fix.


- run ``python manage.py migrate`` to sync the database.

Things to you might need to change in your code, especially if you get a ``AxesModelBackend.RequestParameterRequired``:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This can also be just axes.exceptions.RequestParameterRequired.


class AxesModelBackend(ModelBackend):

class RequestParameterRequired(Exception):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be moved into axes.exceptions file (you can just create that) as per good Django package conventions. Exceptions are easier to find when they are in standard locations.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I strongly disagree eith moving the exception and using suspicious operation, although im always open to naming changes.

Moving indicates it is a failure on the module level, which i dont think applies here. The convention, to me, suggests a "Misconfigured" or "BadParameter" parent exc in exceptions.py that can be subclassed here, but i think the subclass should live here as it is tied to the backend not the module.

Is this a suspicious operation? I think its more programmer error?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But maybe im thinking app as part of a big project wise and not standalone library wise

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OK, I think it's valid to have this here as well. Would be nice to have some tests for this, but I think we can implement those later.

response_context['error'] = error_msg
raise PermissionDenied(error_msg)

# No-op
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could check for the request authentication status in the model backend and lock out if there are duplicate requests.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you please clarify what you mean by 'check for the request authentication status'?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Never mind, I think this is already handled by our signal handler - backend combination.

@aleksihakli
Copy link
Member

I guess the big question is if we want to do the access checking and locking out in the model backend?

@markddavidoff
Copy link
Contributor Author

@aleksihakli, you mean as the default recommended way?

@aleksihakli
Copy link
Member

@markddavidoff yeah! I think the best place for all that checking is the AxesModelBackend introduced here. That way we could catch all authentication requests and check their authentication status neatly in one place instead of monkey patching any views or affecting any other code.

@markddavidoff
Copy link
Contributor Author

@aleksihakli I'm still confused what you mean, i believe this does all the checking the monkeypatched view does already. Am i missing other logic?

@aleksihakli
Copy link
Member

@markddavidoff if the Django authentication signals which have handlers defined in axes/signals.py fire correctly, then axes gets information about user login attempts and logouts. Then this backend should work nicely.

I wondered if we should attempt to do access attempt logging in the backend level, but I suppose that isn't necessary in the case all signals work correctly. We should be able to remove the monkey patching with this change from axes altogether if everything works nicely with this new backend.

I'll go ahead and merge this changeset, let's refine it further in a separate PR if necessary :)

@aleksihakli aleksihakli merged commit d08aaa5 into jazzband:master Apr 11, 2018
@aleksihakli
Copy link
Member

@markddavidoff I ran a bunch of tests on top of the new backend and it seems that authentication succesfully fails from the signals, but raising the PermissionDenied does not correctly produce a 403 code in the backend in our test setup :(

@aleksihakli
Copy link
Member

It seems that authenticate does not indeed return a 403 but just skips the authentication:

https://github.com/django/django/blob/master/django/contrib/auth/__init__.py#L62

So we will either have to modify the tests that check response code on an attempted login, implement a custom middleware that returns the 403 code, or continue monkey patching stuff.

@markddavidoff
Copy link
Contributor Author

markddavidoff commented Apr 11, 2018 via email

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

3 participants