4

I'm using a Django package named django-safedelete that allows to delete users without removing them from the database.

Basically, it adds a delete attribute to the model, and the queries like User.objects.all() won't return the deleted models.

You can still query all objects using a special manager. For example User.objects.all_with_deleted() will return all users , including the deleted ones. User.objects.deleted_only() will return the deleted ones.

This works as expected, except in one case. I'm using Token Authentication for my users with Django Rest Framework 3.9, and in my DRF views, I'm using the built-in permission IsAuthenticated.

Code of a basic CBV I'm using:

class MyView(APIView):

    permission_classes = (IsAuthenticated,)

    def get(self, request):
        return Response(status=HTTP_200_OK)

Code of the DRF implementation of IsAuthenticated permission:

class IsAuthenticated(BasePermission):
    """
    Allows access only to authenticated users.
    """

    def has_permission(self, request, view):
        return bool(request.user and request.user.is_authenticated)

The problem

When a user is soft deleted, he's still able to authenticate using its token.

I'm expecting the user to have a 401 Unauthorized error when he's soft deleted. What's wrong?

David Dahan
  • 10,576
  • 11
  • 64
  • 137

2 Answers2

4

The DRF already uses the is_active property to decide if the user is able to authenticate. Whenever you delete a user, just be sure to set is_active to False at the same time.

For django-safedelete:

Since you're using django-safedelete, you'll have to override the delete() method to de-activate and then use super() to do the original behavior, something like:

class MyUserModel(SafeDeleteModel):
    _safedelete_policy = SOFT_DELETE
    my_field = models.TextField()

    def delete(self, *args, **kwargs):
        self.is_active = False
        super().delete(*args, **kwargs)

    def undelete(self, *args, **kwargs):
        self.is_active = True
        super().undelete(*args, **kwargs)

Note that this works with QuerySets too because the manager for SafeDeleteModel overrides the QuerySet delete() method. (See: https://github.com/makinacorpus/django-safedelete/blob/master/safedelete/queryset.py)

The benefit to this solution is that you do not have to change the auth class on every APIView, and any apps that rely on the is_active property of the User model will behave sanely. Plus, if you don't do this then you'll have deleted objects that are also active, so that doesn't make much sense.

Greg Schmit
  • 4,275
  • 2
  • 21
  • 36
  • Yes, I added a note on that on the end; `django-safedelete` already overrides the QuerySet delete in a smart way. – Greg Schmit May 21 '19 at 16:17
  • if so, that seems great! +1 from me :) – JPG May 21 '19 at 16:20
  • @Greg Schmit Nice answer! 2 thoughts: 1) I think we should override queryset methods too because of the comment: `# TODO: Replace this by bulk update if we can` (means it could change over time and lead to unexpected behaviour). 2) Let's not forget about the `undelete()` methods to reactivate the user. – David Dahan May 27 '19 at 15:47
  • By the way, I think users `is_active` field could (or could not) have a functional meaning that would be lost using this method. In other words, maybe we would want to keep a user inactive when undeleting it. – David Dahan May 27 '19 at 15:49
  • @DavidD. Changing `is_active` is the correct way. In the docs it even says ` We recommend that you set this flag to False instead of deleting accounts; that way, if your applications have any foreign keys to users, the foreign keys won’t break.` (https://docs.djangoproject.com/en/2.2/ref/contrib/auth/) Also, yes also do this in undelete, and override QuerySet if you want, that's perfectly fine. The core of my answer is that the documented and standard way to do what you want to do is by setting `is_active` to False, not by overriding all of your views. – Greg Schmit May 27 '19 at 17:48
  • @Greg Schmit The doc says you should not delete a user, and use `is_active` field instead to emulate the deletion. But it is exactly what safe-delete already does with the `deleted` field (the doc does not expect me to use safe-delete package). So you don't need to use both fields for the exact same purpose, it won't be DRY and could be confusing for beginner. A third solution that agrees with both of us could be to not delete the user at all, and just deactivate him when overriding delete() method. This way, no need to update views, no doubles with fields that does the same thing. – David Dahan May 27 '19 at 21:25
  • @DavidD. If you want to use a package that adds a state field, do it! But the other solution says "yeah, keep `is_active` set to True even though that's not an active user." What I'm saying is: don't do that. De-activate deleted users. What if there is another part of Django that uses `is_active` to see if this is an active user? Now you're going to start down a trail that leads to overriding every part of Django that uses the `is_active` property of the `User` model, rather than just doing the **correct** thing, and ensuring deleted users don't have `is_active` set to `True`. – Greg Schmit May 27 '19 at 21:40
  • What if you have a mass emailer that emails active users (by the `is_active` property)? Don't get me wrong, you can fork it and change that to `deleted`, but it's, in my opinion, hacky, not DRY, and a Bad Idea. – Greg Schmit May 27 '19 at 21:45
  • @Greg Schmit I think you missed the last part of my comment that basically says "I agree with you". To tell it in other words, I think the current best solution is to NOT safe-delete the `User` (no `deleted` field for this model) and override `delete()` method like you said. Just replace `SOFT_DELETE ` with `NO_DELETE` in your answer, and add `undelete()` method, and it will be the best answer imho :) – David Dahan May 27 '19 at 21:48
  • BTW, I'm noticing that I sound like an asshole, but please know that I agree that the other solution fixes your problem and I'm just trying to articulate why I believe my way is better. – Greg Schmit May 27 '19 at 21:48
  • @DavidD. I wouldn't do that either because derivative packages based on `django-safedelete` would expect a `deleted` property and that would break it. If I were to re-write `django-safedelete` I probably would add a feature to set the property name for a particular object in the `settings.py` so that you could define `is_active` as the deleted property name of the `User` model. – Greg Schmit May 27 '19 at 21:51
  • @DavidD. I added the `undelete` part but it seemed pretty self explanatory since it's the same pattern as `delete`. I really don't care about being the accepted answer, it's just that I've never been more certain of one way being more correct than another. – Greg Schmit May 27 '19 at 21:59
  • :D :D Thanks for this :) – David Dahan May 27 '19 at 22:01
  • @GregSchmit last question: why do you call save() in the methods you are overriding? It seems to me that `save()` is gonna be called twice for no particular reason. Am I missing something? – David Dahan May 27 '19 at 22:09
  • 1
    @DavidD. That actually an artifact from an issue I had in another project where I wanted to change a property of an object in the `save` method, but sometimes parts of Django would call `save` with `commit=False`, and so the change that I made would get lost. So now I tend to save in the method to commit the change I want, and then call `super` and pass any args. In this case that doesn't apply probably so it's safe to get rid of those lines. – Greg Schmit May 27 '19 at 22:38
  • @DavidD. Also, if you accidentally set the policy to "NO_DELETE" then by explicitly calling `save` you would at least de-activate the user. But it's more of an advanced topic so I removed it. – Greg Schmit May 27 '19 at 22:41
3

Why?

If we look into the authenticate_credentials() method of DRF TokenAuthentication [source-code], we could see that,

def authenticate_credentials(self, key):
    model = self.get_model()
    try:
        token = model.objects.select_related('user').get(key=key)
    except model.DoesNotExist:
        raise exceptions.AuthenticationFailed(_('Invalid token.'))

    if not token.user.is_active:
        raise exceptions.AuthenticationFailed(_('User inactive or deleted.'))

    return (token.user, token)

Which indicates that it's not filtering out the soft deleted User instances

Solution?

Create a Custom Authentication class and wire-up in the corresponding view

# authentication.py
from rest_framework.authentication import TokenAuthentication, exceptions, _


class CustomTokenAuthentication(TokenAuthentication):
    def authenticate_credentials(self, key):
        model = self.get_model()
        try:
            token = model.objects.select_related('user').get(key=key)
        except model.DoesNotExist:
            raise exceptions.AuthenticationFailed(_('Invalid token.'))

        if not token.user.is_active or not token.user.deleted: # Here I added something new !!
            raise exceptions.AuthenticationFailed(_('User inactive or deleted.'))

        return (token.user, token)

and wire-up in views

# views.py
from rest_framework.views import APIView


class MyView(APIView):
    authentication_classes = (CustomTokenAuthentication,)
    permission_classes = (IsAuthenticated,)

    def get(self, request):
        return Response(status=HTTP_200_OK)
JPG
  • 82,442
  • 19
  • 127
  • 206
  • 1
    Wouldn't it be easier to ensure that you set `is_active` to `False` when deleting a user? – Greg Schmit May 21 '19 at 16:00
  • Also, then wouldn't you have to change every APIView? – Greg Schmit May 21 '19 at 16:02
  • Yeah...But, set `is_active=False` while user deletion would be another headache. I don't find and an easy way to do ***programmatically*** that thing while user deletion, do you? – JPG May 21 '19 at 16:04
  • Yes, although since he's using another package to do that he would have to override something there. The benefit is that it would be a single change. Your solution requires every APIView to be changed. – Greg Schmit May 21 '19 at 16:06
  • Yeah, he just needs to override `delete()` for the `User` model. – Greg Schmit May 21 '19 at 16:13