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

knox: handle race condition on concurrent logout call #186

Open
wants to merge 1 commit into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
## UNRELEASED

- Fix race condition on concurrent logout call

## 4.1.0

- Expiry format now defaults to whatever is used Django REST framework
Expand Down
9 changes: 8 additions & 1 deletion knox/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ def compare_digest(a, b):
import binascii

from django.contrib.auth import get_user_model
from django.db import DatabaseError
from django.utils import timezone
from django.utils.translation import ugettext_lazy as _
from rest_framework import exceptions
Expand Down Expand Up @@ -73,7 +74,13 @@ def authenticate_credentials(self, token):
raise exceptions.AuthenticationFailed(msg)
if compare_digest(digest, auth_token.digest):
if knox_settings.AUTO_REFRESH and auth_token.expiry:
self.renew_token(auth_token)
# It may happen that a token gets deleted while we try
# to update its expiry, catch that and consider the token
# invalid
try:
Copy link
Collaborator

Choose a reason for hiding this comment

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

I would rather move this try/except around the save call within renew_token. Also, I’m worried that swallowing every database error like this could lead to some other unrelated error being swallowed. Can we check the error code ? The error message maybe ?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

The .save is the only database interaction in that hunk of code so there's not much chances to swallow unrelated exceptions. Regarding moving the handling inside renew_token I don't see much value in it: it will duplicate the error handling as we really can do something sensible only in authenticate_credentials.

Copy link
Collaborator

Choose a reason for hiding this comment

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

You could swallow any other database error and mistaken it with an authentication error which would be confusing in a debug session. renew_token could change in the future and have more database interactions so I think moving it around the save makes sense. I don’t understand your argument about code duplication as this is exactly what I’m trying to avoid, using refresh token somewhere else would require to duplicate the error handling, right? Moving the error handling inside refresh token makes it safe to be used anywhere.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

By duplicating error handling i mean that on top of the exception handling i have to check the return value. In my opinion it's making the code less explicit.

diff --git a/knox/auth.py b/knox/auth.py
index 1d453ca..5345175 100644
--- a/knox/auth.py
+++ b/knox/auth.py
@@ -74,10 +74,8 @@ class TokenAuthentication(BaseAuthentication):
                 raise exceptions.AuthenticationFailed(msg)
             if compare_digest(digest, auth_token.digest):
                 if knox_settings.AUTO_REFRESH and auth_token.expiry:
-                    try:
-                        self.renew_token(auth_token)
-                    except DatabaseError:
-                        # avoid race condition with concurrent logout calls
+                    renewed = self.renew_token(auth_token)
+                    if not renewed:
                         continue
                 return self.validate_user(auth_token)
         raise exceptions.AuthenticationFailed(msg)
@@ -89,7 +87,12 @@ class TokenAuthentication(BaseAuthentication):
         # Throttle refreshing of token to avoid db writes
         delta = (new_expiry - current_expiry).total_seconds()
         if delta > knox_settings.MIN_REFRESH_INTERVAL:
-            auth_token.save(update_fields=('expiry',))
+            try:
+                auth_token.save(update_fields=('expiry',))
+            except DatabaseError:
+                # avoid race condition with concurrent logout calls
+                return False
+        return True

Copy link
Collaborator

Choose a reason for hiding this comment

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

Yes I see your point now. I still have a code smell around this. I’m even wondering if the authentication failed error makes sense in this case? You logged in but it failed to refresh... Maybe raising a 409 (conflict) would be better, we could do a get right before the save and trigger the exception if the get fails ? That would remove the database error check I don’t like and the error checking duplication you don’t like.

Copy link
Collaborator

Choose a reason for hiding this comment

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

@xrmx this is a very valid point, thanks for the valuable link as well which helped refresh my mind on this topic 👍

Another approach we could then take and which would probably make more sense would be to do a select for update when getting the token on login.

The rationale would be that as soon as a login is in progress, no change should be applied to a token. Basically, one can not change / destroy a token if someone is actually using it to log in but they should instead retry after the login has completed properly.

What do you think ?

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 think we are overthinking this a bit :) I think in this case taking locks will create more chances to mess things than what are preventing.

Copy link
Collaborator

Choose a reason for hiding this comment

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

Not sure, what do you have in mind that could make this worse?

Copy link
Collaborator

Choose a reason for hiding this comment

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

oh and I just wanted to add that I agree on the overthinking bit as well, this is the feeling I have too but I don't see how to solve it simply while being "correct". Maybe we should leave it as is 😝

Copy link
Contributor Author

Choose a reason for hiding this comment

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

A worse scenario for me is code taking locks and not releasing them

self.renew_token(auth_token)
except DatabaseError:
break
return self.validate_user(auth_token)
raise exceptions.AuthenticationFailed(msg)

Expand Down
25 changes: 25 additions & 0 deletions tests/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,11 @@
from datetime import datetime, timedelta

from django.contrib.auth import get_user_model
from django.db import DatabaseError
from django.test import override_settings
from django.utils.six.moves import reload_module
from freezegun import freeze_time
from rest_framework.exceptions import AuthenticationFailed
from rest_framework.serializers import DateTimeField
from rest_framework.test import APIRequestFactory, APITestCase as TestCase

Expand All @@ -15,6 +17,13 @@
from knox.settings import CONSTANTS, knox_settings
from knox.signals import token_expired

try:
# Python 3
from unittest import mock
except ImportError:
# Python 2
import mock

try:
# For django >= 2.0
from django.urls import reverse
Expand Down Expand Up @@ -396,3 +405,19 @@ def test_expiry_is_present(self):
response.data['expiry'],
DateTimeField().to_representation(AuthToken.objects.first().expiry)
)

def test_authenticate_credentials_handles_expiry_update_of_gone_token(self):
"""This tests a race condition of an authentication against logout

It may happen that a token gets deleted while we are inside
authenticate_credentials with the Django ORM raising a DatabaseError
when trying to update the expiry time."""

instance, token = AuthToken.objects.create(user=self.user)
with override_settings(REST_KNOX=auto_refresh_knox):
reload_module(auth)
token_auth = TokenAuthentication()
with mock.patch.object(token_auth, 'renew_token') as m:
m.side_effect = DatabaseError()
with self.assertRaises(AuthenticationFailed):
token_auth.authenticate_credentials(token.encode('utf-8'))
xrmx marked this conversation as resolved.
Show resolved Hide resolved
1 change: 1 addition & 0 deletions tox.ini
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ deps =
django22: Django>=2.2,<2.3
django-nose
markdown<3.0
mock
isort
djangorestframework
freezegun
Expand Down