Skip to content

Commit

Permalink
Fixes #15, #16: allow select_related | tests for permissions, auth …
Browse files Browse the repository at this point in the history
…modules (#17)
  • Loading branch information
eshaan7 authored May 19, 2021
1 parent 29fce15 commit dc1c398
Show file tree
Hide file tree
Showing 14 changed files with 282 additions and 76 deletions.
14 changes: 14 additions & 0 deletions docs/source/changelog.rst
Original file line number Diff line number Diff line change
@@ -1,6 +1,20 @@
Changelog
============

`v0.3.0 <https://github.com/eshaan7/django-rest-durin/releases/tag/v0.3.0>`__
--------------------------------------------------------------------------------

**Features:**

- `AUTHTOKEN_SELECT_RELATED_LIST <settings.html#AUTHTOKEN_SELECT_RELATED_LIST>`_ setting to enable performance optimization. (Issue 16_)

**Other:**

- Test cases now cover the :doc:`permissions`.

.. _16: https://github.com/Eshaan7/django-rest-durin/issues/16


`v0.2.0 <https://github.com/eshaan7/django-rest-durin/releases/tag/v0.2.0>`__
--------------------------------------------------------------------------------

Expand Down
19 changes: 18 additions & 1 deletion docs/source/settings.rst
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ Example ``settings.py``::
"EXPIRY_DATETIME_FORMAT": api_settings.DATETIME_FORMAT,
"TOKEN_CACHE_TIMEOUT": 60,
"REFRESH_TOKEN_ON_LOGIN": False,
"AUTHTOKEN_SELECT_RELATED_LIST": ["user"],
}
#...snip...

Expand Down Expand Up @@ -83,4 +84,20 @@ Example ``settings.py``::

In the first case, the already existing token is sent in response.
So this setting if set to ``True`` should extend the expiry time of the
token by it's :class:`durin.models.Client` ``token_ttl`` everytime login happens.
token by it's :class:`durin.models.Client` ``token_ttl`` everytime login happens.

.. data:: AUTHTOKEN_SELECT_RELATED_LIST

Default: ``["user"]``

This is passed as an argument to ``select_related`` when the :class:`durin.auth.TokenAuthentication` class
fetches the :class:`durin.models.AuthToken` instance. For example,

.. code-block:: python
AuthToken.objects.select_related(*AUTHTOKEN_SELECT_RELATED_LIST).get(token=token_string)
Otherwise, set to a falsy value such as ``None`` or ``False`` to not use ``select_related``.

.. Hint:: Refer to `Django's select_related docs <https://docs.djangoproject.com/en/3.2/ref/models/querysets/#select-related>`_
to see how this can boost performance by reducing number of SQL queries made.
15 changes: 13 additions & 2 deletions durin/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,9 +53,20 @@ def authenticate_credentials(cls, token):
"""
Verify that the given token exists in the database
"""
token = token.decode("utf-8")
token_str = token.decode("utf-8")
try:
auth_token = AuthToken.objects.get(token=token)
# read settings
to_select = durin_settings.AUTHTOKEN_SELECT_RELATED_LIST

# get AuthToken object
if isinstance(to_select, list):
auth_token = AuthToken.objects.select_related(*to_select).get(
token=token_str
)
else:
auth_token = AuthToken.objects.get(token=token_str)

# validate token
if cls._cleanup_token(auth_token):
e = _("The given token has expired.")
raise exceptions.AuthenticationFailed(e)
Expand Down
3 changes: 2 additions & 1 deletion durin/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,8 @@ class Client(models.Model):

def __str__(self):
td = humanize.naturaldelta(self.token_ttl)
return "({0}, {1})".format(self.name, td)
rate = self.throttle_rate or "null"
return "({0}: {1}, {2})".format(self.name, td, rate)


class AuthTokenManager(models.Manager):
Expand Down
8 changes: 4 additions & 4 deletions durin/permissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,9 @@ class AllowSpecificClients(BasePermission):
allowed_clients_name = ()

def has_permission(self, request, view):
if not hasattr(request, "_auth"):
if not request.auth:
return False
return request._auth.client.name in self.allowed_clients_name
return request.auth.client.name in self.allowed_clients_name


class DisallowSpecificClients(BasePermission):
Expand All @@ -41,6 +41,6 @@ class DisallowSpecificClients(BasePermission):
disallowed_clients_name = ()

def has_permission(self, request, view):
if not hasattr(request, "_auth"):
if not request.auth:
return False
return request._auth.client.name not in self.disallowed_clients_name
return request.auth.client.name not in self.disallowed_clients_name
1 change: 1 addition & 0 deletions durin/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
"EXPIRY_DATETIME_FORMAT": api_settings.DATETIME_FORMAT,
"TOKEN_CACHE_TIMEOUT": 60,
"REFRESH_TOKEN_ON_LOGIN": False,
"AUTHTOKEN_SELECT_RELATED_LIST": ["user"],
}

IMPORT_STRINGS = {
Expand Down
13 changes: 13 additions & 0 deletions example_project/permissions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
from durin import permissions

TEST_CLIENT_NAME = "web-browser-client-test"


class CustomAllowSpecificClients(permissions.AllowSpecificClients):

allowed_clients_name = (TEST_CLIENT_NAME,)


class CustomDisallowSpecificClients(permissions.DisallowSpecificClients):

disallowed_clients_name = (TEST_CLIENT_NAME,)
18 changes: 17 additions & 1 deletion example_project/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,13 @@
from django.urls import include, path, re_path
from django.views.generic.base import RedirectView

from .views import CachedRootView, RootView, ThrottledView
from .views import (
CachedRootView,
NoWebClientView,
OnlyWebClientView,
RootView,
ThrottledView,
)

urlpatterns = [
path("", RedirectView.as_view(url="admin/", permanent=False)),
Expand All @@ -11,4 +17,14 @@
re_path(r"^api/$", RootView.as_view(), name="api-root"),
re_path(r"^api/cached$", CachedRootView.as_view(), name="cached-auth-api"),
re_path(r"^api/throttled$", ThrottledView.as_view(), name="throttled-api"),
re_path(
r"^api/onlywebclient$",
OnlyWebClientView.as_view(),
name="onlywebclient-api",
),
re_path(
r"^api/nowebclient$",
NoWebClientView.as_view(),
name="nowebclient-api",
),
]
35 changes: 29 additions & 6 deletions example_project/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,27 +5,50 @@
from durin.auth import CachedTokenAuthentication, TokenAuthentication
from durin.throttling import UserClientRateThrottle

from .permissions import CustomAllowSpecificClients, CustomDisallowSpecificClients

class RootView(APIView):

class _BaseAPIView(APIView):
authentication_classes = (TokenAuthentication,)
permission_classes = (IsAuthenticated,)


class RootView(_BaseAPIView):
def get(self, request):
return Response("api root")


class CachedRootView(APIView):
class CachedRootView(_BaseAPIView):
authentication_classes = (CachedTokenAuthentication,)
permission_classes = (IsAuthenticated,)

def get(self, request):
return Response("cached api root")


class ThrottledView(APIView):
authentication_classes = (TokenAuthentication,)
permission_classes = (IsAuthenticated,)
class ThrottledView(_BaseAPIView):
throttle_classes = (UserClientRateThrottle,)

def get(self, request):
return Response("ThrottledView")


class OnlyWebClientView(_BaseAPIView):
"""
Only accessible to TEST_CLIENT_NAME
"""

permission_classes = (CustomAllowSpecificClients, IsAuthenticated)

def get(self, request):
return Response("OnlyWebClientView")


class NoWebClientView(_BaseAPIView):
"""
Not accessible to TEST_CLIENT_NAME
"""

permission_classes = (CustomDisallowSpecificClients, IsAuthenticated)

def get(self, request):
return Response("NoWebClientView")
3 changes: 2 additions & 1 deletion tests/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
from django.core.cache import cache as default_cache
from rest_framework.test import APITestCase

from durin.models import Client
from durin.models import AuthToken, Client

User = get_user_model()

Expand All @@ -11,6 +11,7 @@ class CustomTestCase(APITestCase):
def setUp(self):
# cleanup
default_cache.clear()
AuthToken.objects.all().delete()
Client.objects.all().delete()
# setup
self.authclient = Client.objects.create(name="authclientfortest")
Expand Down
64 changes: 64 additions & 0 deletions tests/test_auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
from importlib import reload

from django.db import reset_queries
from django.test import override_settings
from django.urls import reverse
from rest_framework.test import APIRequestFactory

from durin import auth
from durin.models import AuthToken, Client
from durin.settings import durin_settings

from . import CustomTestCase

root_url = reverse("api-root")

new_settings = durin_settings.defaults.copy()


class AuthTestCase(CustomTestCase):
def setUp(self):
super().setUp()
# authenticate client
self.token_instance = AuthToken.objects.create(self.user, self.authclient)
self.client.credentials(
HTTP_AUTHORIZATION=("Token %s" % self.token_instance.token)
)
# reset queries
reset_queries()
self.assertNumQueries(0, msg="Queries were reset")

def test_authtoken_lookup_1_sql_query(self):
with self.assertNumQueries(
1,
msg="Since we use ``select_related`` it should take only 1 query",
):
resp = self.client.get(root_url)
self.assertEqual(resp.status_code, 200)

def test_authtoken_lookup_2_sql_query(self):
# override settings
new_settings["AUTHTOKEN_SELECT_RELATED_LIST"] = False
with override_settings(REST_DURIN=new_settings):
reload(auth)
with self.assertNumQueries(
2,
msg="Since we didn't use ``select_related`` it should take 2 queries",
):
resp = self.client.get(root_url)
self.assertEqual(resp.status_code, 200)

def test_update_token_key(self):
self.assertEqual(AuthToken.objects.count(), 1)
self.assertEqual(Client.objects.count(), 1)
rf = APIRequestFactory()
request = rf.get("/")
request.META = {
"HTTP_AUTHORIZATION": "Token {}".format(self.token_instance.token)
}
(auth_user, auth_token) = auth.TokenAuthentication().authenticate(request)
self.assertEqual(
self.token_instance.token,
auth_token.token,
)
self.assertEqual(self.user, auth_user)
45 changes: 45 additions & 0 deletions tests/test_models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
from django.core.exceptions import ValidationError as DjValidationError
from django.test import TestCase

from durin.models import Client


class ClientTestCase(TestCase):
@classmethod
def setUpClass(cls):
cls.client_names = ["web", "mobile", "cli"]
return super().setUpClass()

def test_create_clients(self):
Client.objects.all().delete()
self.assertEqual(Client.objects.count(), 0)
for name in self.client_names:
Client.objects.create(name=name)
self.assertEqual(Client.objects.count(), len(self.client_names))

def test_throttle_rate_validation_ok(self):
testclient = Client.objects.create(
name="test_throttle_rate_validation", throttle_rate="2/m"
)
testclient.full_clean()

self.assertIsNotNone(testclient.pk)
self.assertIsNotNone(testclient.token_ttl)
self.assertIsNotNone(testclient.throttle_rate)

def test_throttle_rate_validation_raises_exc(self):

with self.assertRaises(DjValidationError):
testclient1 = Client.objects.create(
name="testclient1", throttle_rate="blahblah"
)
testclient1.full_clean()
testclient1.delete()

with self.assertRaises(DjValidationError):
testclient2 = Client.objects.create(
name="testclient2",
throttle_rate="2/minute",
)
testclient2.full_clean()
testclient2.delete()
Loading

0 comments on commit dc1c398

Please sign in to comment.