Implementing the custom permissions from the Django REST framework tutorial
Question:
I’m following the Django REST framework tutorial (part 4) but I’m observing a discrepancy between the behavior of my API and what is expected according to the tutorial. Here is my project-level urls.py
:
from django.contrib import admin
from django.urls import path, include
from rest_framework.urlpatterns import format_suffix_patterns
from snippets import views
urlpatterns = [
path('admin/', admin.site.urls),
path('users/', views.UserList.as_view()),
path('users/<int:pk>/', views.UserDetail.as_view()),
path('', include('snippets.urls'))
]
urlpatterns += [
path('api-auth/', include('rest_framework.urls'))
]
and here is the included snippets.urls
:
from django.urls import path
from rest_framework.urlpatterns import format_suffix_patterns
from snippets import views
urlpatterns = [
path('snippets/', views.SnippetList.as_view()),
path('snippets/<int:pk>/', views.SnippetDetail.as_view())
]
urlpatterns = format_suffix_patterns(urlpatterns)
I’ve created a permissions.py
with a custom permission class IsOwnerOrReadOnly
:
from rest_framework import permissions
class IsOwnerOrReadOnly(permissions.BasePermission):
"""
Custom permission to only allow owners of an object to edit it.
"""
def has_object_permission(self, request, view, obj):
# Read permissions are allowed to any request,
# so we'll always allow GET, HEAD or OPTIONS requests.
if request.method in permissions.SAFE_METHODS:
return True
# Write permissions are only allowed to the owner of the snippet.
return obj.owner == request.user
and I’ve added this class to permission_classes
in the SnippetDetail
class in views.py
:
from django.contrib.auth.models import User
from snippets.models import Snippet
from snippets.serializers import SnippetSerializer, UserSerializer
from rest_framework import generics, permissions
from snippets.permissions import IsOwnerOrReadOnly
class UserList(generics.ListAPIView):
queryset = User.objects.all()
serializer_class = UserSerializer
class UserDetail(generics.RetrieveAPIView):
queryset = User.objects.all()
serializer_class = UserSerializer
class SnippetList(generics.ListCreateAPIView):
queryset = Snippet.objects.all()
serializer_class = SnippetSerializer
permission_classes = (permissions.IsAuthenticatedOrReadOnly,)
def perform_create(self, serializer):
serializer.save(owner=self.request.user)
class SnippetDetail(generics.RetrieveUpdateDestroyAPIView):
queryset = Snippet.objects.all()
serializer_class = SnippetSerializer
permission_classes = (permissions.IsAuthenticatedOrReadOnly, IsOwnerOrReadOnly,)
However, when I make a POST request specifying only my credentials (using Httpie), I get a 400 Bad Request:
Kurts-MacBook-Pro:~ kurtpeek$ http -a kurtpeek:mypassword POST http://localhost:8000/snippets/ code="print 789"
HTTP/1.1 400 Bad Request
Allow: GET, POST, HEAD, OPTIONS
Content-Length: 37
Content-Type: application/json
Date: Wed, 24 Jan 2018 18:13:47 GMT
Server: WSGIServer/0.2 CPython/3.6.4
Vary: Accept, Cookie
X-Frame-Options: SAMEORIGIN
{
"owner": [
"This field is required."
]
}
Apparently, I need to specify owner="1"
to get this to work:
Kurts-MacBook-Pro:~ kurtpeek$ http -a kurtpeek:mypassword POST http://localhost:8000/snippets/ code="print 789" owner="1"
HTTP/1.1 201 Created
Allow: GET, POST, HEAD, OPTIONS
Content-Length: 103
Content-Type: application/json
Date: Wed, 24 Jan 2018 18:13:35 GMT
Server: WSGIServer/0.2 CPython/3.6.4
Vary: Accept, Cookie
X-Frame-Options: SAMEORIGIN
{
"code": "print 789",
"id": 3,
"language": "python",
"linenos": false,
"owner": 1,
"style": "friendly",
"title": ""
}
However, as I understand from the logic of the has_object_permission
method of the IsOwnerOrReadOnly
permissions class, the owner should be inferred from the request.user
. This is also not how the example is given in the tutorial. Can anyone spot what’s wrong here?
Answers:
This has to do with how your serializer is defined/used, it isn’t a permission issue. The field ‘owner’ needs to be set as read only or not required in the serializer.
The POST request should go to your SnippetList
view, not to SnippetDetail
, so the custom permission class isn’t used.
I don’t know why you get this error, though. According to the tutorial, you should set owner
to be a read-only field in SnippetSerializer
.
owner = serializers.ReadOnlyField(source='owner.username')
First, create a "permission.py" file in your project directory and write the following code:
import jwt
from django.contrib.auth import get_user_model
from django.utils.encoding import smart_text
from django.utils.translation import ugettext as _
from rest_framework import exceptions, permissions
from rest_framework.authentication import BaseAuthentication, get_authorization_header
from rest_framework_jwt.settings import api_settings
jwt_decode_handler = api_settings.JWT_DECODE_HANDLER
jwt_get_username_from_payload = api_settings.JWT_PAYLOAD_GET_USERNAME_HANDLER
class JSONWebTokenAuthentication(BaseAuthentication):
"""
Clients should authenticate by passing the token key in the "Authorization"
HTTP header, prepended with the string specified in the setting
`JWT_AUTH_HEADER_PREFIX`. For example:
Authorization: JWT eyJhbGciOiAiSFMyNTYiLCAidHlwIj
"""
www_authenticate_realm = "api"
def authenticate(self, request):
"""
Returns a two-tuple of `User` and token if a valid signature has been
supplied using JWT-based authentication. Otherwise returns `None`.
"""
jwt_value = self.get_jwt_value(request)
if not jwt_value:
msg = _("JWT token are not found!.")
raise exceptions.AuthenticationFailed(msg)
try:
payload = jwt_decode_handler(jwt_value)
except jwt.ExpiredSignature:
msg = _("Signature has expired.")
raise exceptions.AuthenticationFailed(msg)
except jwt.DecodeError:
msg = _("Error decoding signature.")
raise exceptions.AuthenticationFailed(msg)
except jwt.InvalidTokenError:
raise exceptions.AuthenticationFailed()
user = self.authenticate_credentials(payload)
return user
def authenticate_credentials(self, payload):
"""
Returns an active user that matches the payload's user id and email.
"""
User = get_user_model()
username = jwt_get_username_from_payload(payload)
if not username:
msg = _("Invalid payload.")
raise exceptions.AuthenticationFailed(msg)
try:
user = User.objects.get_by_natural_key(username)
except User.DoesNotExist:
msg = _("Invalid username.")
raise exceptions.AuthenticationFailed(msg)
if not user.is_active:
msg = _("User account is disabled.")
raise exceptions.AuthenticationFailed(msg)
return user
def get_jwt_value(self, request):
auth = get_authorization_header(request).split()
auth_header_prefix = api_settings.JWT_AUTH_HEADER_PREFIX.lower()
if not auth:
if api_settings.JWT_AUTH_COOKIE:
return request.COOKIES.get(api_settings.JWT_AUTH_COOKIE)
return None
if smart_text(auth[0].lower()) != auth_header_prefix:
return None
if len(auth) == 1:
msg = _("Invalid Authorization header. No credentials provided.")
raise exceptions.AuthenticationFailed(msg)
elif len(auth) > 2:
msg = _(
"Invalid Authorization header. Credentials string "
"should not contain spaces."
)
raise exceptions.AuthenticationFailed(msg)
return auth[1]
def authenticate_header(self, request):
"""
Return a string to be used as the value of the `WWW-Authenticate`
header in a `401 Unauthenticated` response, or `None` if the
authentication scheme should return `403 Permission Denied` responses.
"""
return '{0} realm="{1}"'.format(
api_settings.JWT_AUTH_HEADER_PREFIX, self.www_authenticate_realm
)
class BasePermissions(JSONWebTokenAuthentication, permissions.DjangoModelPermissions):
perms_map = {
"GET": ["%(app_label)s.view_%(model_name)s"],
"OPTIONS": [],
"HEAD": [],
"POST": ["%(app_label)s.add_%(model_name)s"],
"PUT": ["%(app_label)s.change_%(model_name)s"],
"PATCH": ["%(app_label)s.change_%(model_name)s"],
"DELETE": ["%(app_label)s.delete_%(model_name)s"],
}
authenticated_users_only = True
def get_required_permissions(self, method, model_cls):
"""
Given a model and an HTTP method, return the list of permission
codes that the user is required to have.
"""
kwargs = {
"app_label": model_cls._meta.app_label,
"model_name": model_cls._meta.model_name,
}
if method not in self.perms_map:
raise exceptions.MethodNotAllowed(method)
return [perm % kwargs for perm in self.perms_map[method]]
def _queryset(self, view):
assert (
hasattr(view, "get_queryset") or getattr(view, "queryset", None) is not None
), (
"Cannot apply {} on a view that does not set "
"`.queryset` or have a `.get_queryset()` method."
).format(
self.__class__.__name__
)
if hasattr(view, "get_queryset"):
queryset = view.get_queryset()
assert queryset is not None, "{}.get_queryset() returned None".format(
view.__class__.__name__
)
return queryset
return view.queryset
class IsAuthenticatePermission(BasePermissions):
def has_permission(self, request, view):
# Workaround to ensure DjangoModelPermissions are not applied
# to the root view when using DefaultRouter.
if getattr(view, "_ignore_model_permissions", False):
return True
validate_user = self.authenticate(request)
request.user = validate_user
if not validate_user or (
not validate_user.is_authenticated and self.authenticated_users_only
):
return False
return True if validate_user else False
class ModelPermission(BasePermissions):
def has_permission(self, request, view):
# Workaround to ensure DjangoModelPermissions are not applied
# to the root view when using DefaultRouter.
if getattr(view, "_ignore_model_permissions", False):
return True
validate_user = self.authenticate(request)
request.user = validate_user
if not request.user or (
not request.user.is_authenticated and self.authenticated_users_only
):
return False
if request.user.is_authenticated and request.user.is_superuser:
return True
queryset = self._queryset(view)
perms = self.get_required_permissions(request.method, queryset.model)
return request.user.has_perms(perms)
class AdminPermission(permissions.BasePermission):
def has_admin_permission(self, request):
if request.user and request.user.is_authenticated and request.user.is_superuser:
return True
return False
After creating the file defined in a setting like this:
"DEFAULT_PERMISSION_CLASSES": (
"your_file_path"
),
I’m following the Django REST framework tutorial (part 4) but I’m observing a discrepancy between the behavior of my API and what is expected according to the tutorial. Here is my project-level urls.py
:
from django.contrib import admin
from django.urls import path, include
from rest_framework.urlpatterns import format_suffix_patterns
from snippets import views
urlpatterns = [
path('admin/', admin.site.urls),
path('users/', views.UserList.as_view()),
path('users/<int:pk>/', views.UserDetail.as_view()),
path('', include('snippets.urls'))
]
urlpatterns += [
path('api-auth/', include('rest_framework.urls'))
]
and here is the included snippets.urls
:
from django.urls import path
from rest_framework.urlpatterns import format_suffix_patterns
from snippets import views
urlpatterns = [
path('snippets/', views.SnippetList.as_view()),
path('snippets/<int:pk>/', views.SnippetDetail.as_view())
]
urlpatterns = format_suffix_patterns(urlpatterns)
I’ve created a permissions.py
with a custom permission class IsOwnerOrReadOnly
:
from rest_framework import permissions
class IsOwnerOrReadOnly(permissions.BasePermission):
"""
Custom permission to only allow owners of an object to edit it.
"""
def has_object_permission(self, request, view, obj):
# Read permissions are allowed to any request,
# so we'll always allow GET, HEAD or OPTIONS requests.
if request.method in permissions.SAFE_METHODS:
return True
# Write permissions are only allowed to the owner of the snippet.
return obj.owner == request.user
and I’ve added this class to permission_classes
in the SnippetDetail
class in views.py
:
from django.contrib.auth.models import User
from snippets.models import Snippet
from snippets.serializers import SnippetSerializer, UserSerializer
from rest_framework import generics, permissions
from snippets.permissions import IsOwnerOrReadOnly
class UserList(generics.ListAPIView):
queryset = User.objects.all()
serializer_class = UserSerializer
class UserDetail(generics.RetrieveAPIView):
queryset = User.objects.all()
serializer_class = UserSerializer
class SnippetList(generics.ListCreateAPIView):
queryset = Snippet.objects.all()
serializer_class = SnippetSerializer
permission_classes = (permissions.IsAuthenticatedOrReadOnly,)
def perform_create(self, serializer):
serializer.save(owner=self.request.user)
class SnippetDetail(generics.RetrieveUpdateDestroyAPIView):
queryset = Snippet.objects.all()
serializer_class = SnippetSerializer
permission_classes = (permissions.IsAuthenticatedOrReadOnly, IsOwnerOrReadOnly,)
However, when I make a POST request specifying only my credentials (using Httpie), I get a 400 Bad Request:
Kurts-MacBook-Pro:~ kurtpeek$ http -a kurtpeek:mypassword POST http://localhost:8000/snippets/ code="print 789"
HTTP/1.1 400 Bad Request
Allow: GET, POST, HEAD, OPTIONS
Content-Length: 37
Content-Type: application/json
Date: Wed, 24 Jan 2018 18:13:47 GMT
Server: WSGIServer/0.2 CPython/3.6.4
Vary: Accept, Cookie
X-Frame-Options: SAMEORIGIN
{
"owner": [
"This field is required."
]
}
Apparently, I need to specify owner="1"
to get this to work:
Kurts-MacBook-Pro:~ kurtpeek$ http -a kurtpeek:mypassword POST http://localhost:8000/snippets/ code="print 789" owner="1"
HTTP/1.1 201 Created
Allow: GET, POST, HEAD, OPTIONS
Content-Length: 103
Content-Type: application/json
Date: Wed, 24 Jan 2018 18:13:35 GMT
Server: WSGIServer/0.2 CPython/3.6.4
Vary: Accept, Cookie
X-Frame-Options: SAMEORIGIN
{
"code": "print 789",
"id": 3,
"language": "python",
"linenos": false,
"owner": 1,
"style": "friendly",
"title": ""
}
However, as I understand from the logic of the has_object_permission
method of the IsOwnerOrReadOnly
permissions class, the owner should be inferred from the request.user
. This is also not how the example is given in the tutorial. Can anyone spot what’s wrong here?
This has to do with how your serializer is defined/used, it isn’t a permission issue. The field ‘owner’ needs to be set as read only or not required in the serializer.
The POST request should go to your SnippetList
view, not to SnippetDetail
, so the custom permission class isn’t used.
I don’t know why you get this error, though. According to the tutorial, you should set owner
to be a read-only field in SnippetSerializer
.
owner = serializers.ReadOnlyField(source='owner.username')
First, create a "permission.py" file in your project directory and write the following code:
import jwt
from django.contrib.auth import get_user_model
from django.utils.encoding import smart_text
from django.utils.translation import ugettext as _
from rest_framework import exceptions, permissions
from rest_framework.authentication import BaseAuthentication, get_authorization_header
from rest_framework_jwt.settings import api_settings
jwt_decode_handler = api_settings.JWT_DECODE_HANDLER
jwt_get_username_from_payload = api_settings.JWT_PAYLOAD_GET_USERNAME_HANDLER
class JSONWebTokenAuthentication(BaseAuthentication):
"""
Clients should authenticate by passing the token key in the "Authorization"
HTTP header, prepended with the string specified in the setting
`JWT_AUTH_HEADER_PREFIX`. For example:
Authorization: JWT eyJhbGciOiAiSFMyNTYiLCAidHlwIj
"""
www_authenticate_realm = "api"
def authenticate(self, request):
"""
Returns a two-tuple of `User` and token if a valid signature has been
supplied using JWT-based authentication. Otherwise returns `None`.
"""
jwt_value = self.get_jwt_value(request)
if not jwt_value:
msg = _("JWT token are not found!.")
raise exceptions.AuthenticationFailed(msg)
try:
payload = jwt_decode_handler(jwt_value)
except jwt.ExpiredSignature:
msg = _("Signature has expired.")
raise exceptions.AuthenticationFailed(msg)
except jwt.DecodeError:
msg = _("Error decoding signature.")
raise exceptions.AuthenticationFailed(msg)
except jwt.InvalidTokenError:
raise exceptions.AuthenticationFailed()
user = self.authenticate_credentials(payload)
return user
def authenticate_credentials(self, payload):
"""
Returns an active user that matches the payload's user id and email.
"""
User = get_user_model()
username = jwt_get_username_from_payload(payload)
if not username:
msg = _("Invalid payload.")
raise exceptions.AuthenticationFailed(msg)
try:
user = User.objects.get_by_natural_key(username)
except User.DoesNotExist:
msg = _("Invalid username.")
raise exceptions.AuthenticationFailed(msg)
if not user.is_active:
msg = _("User account is disabled.")
raise exceptions.AuthenticationFailed(msg)
return user
def get_jwt_value(self, request):
auth = get_authorization_header(request).split()
auth_header_prefix = api_settings.JWT_AUTH_HEADER_PREFIX.lower()
if not auth:
if api_settings.JWT_AUTH_COOKIE:
return request.COOKIES.get(api_settings.JWT_AUTH_COOKIE)
return None
if smart_text(auth[0].lower()) != auth_header_prefix:
return None
if len(auth) == 1:
msg = _("Invalid Authorization header. No credentials provided.")
raise exceptions.AuthenticationFailed(msg)
elif len(auth) > 2:
msg = _(
"Invalid Authorization header. Credentials string "
"should not contain spaces."
)
raise exceptions.AuthenticationFailed(msg)
return auth[1]
def authenticate_header(self, request):
"""
Return a string to be used as the value of the `WWW-Authenticate`
header in a `401 Unauthenticated` response, or `None` if the
authentication scheme should return `403 Permission Denied` responses.
"""
return '{0} realm="{1}"'.format(
api_settings.JWT_AUTH_HEADER_PREFIX, self.www_authenticate_realm
)
class BasePermissions(JSONWebTokenAuthentication, permissions.DjangoModelPermissions):
perms_map = {
"GET": ["%(app_label)s.view_%(model_name)s"],
"OPTIONS": [],
"HEAD": [],
"POST": ["%(app_label)s.add_%(model_name)s"],
"PUT": ["%(app_label)s.change_%(model_name)s"],
"PATCH": ["%(app_label)s.change_%(model_name)s"],
"DELETE": ["%(app_label)s.delete_%(model_name)s"],
}
authenticated_users_only = True
def get_required_permissions(self, method, model_cls):
"""
Given a model and an HTTP method, return the list of permission
codes that the user is required to have.
"""
kwargs = {
"app_label": model_cls._meta.app_label,
"model_name": model_cls._meta.model_name,
}
if method not in self.perms_map:
raise exceptions.MethodNotAllowed(method)
return [perm % kwargs for perm in self.perms_map[method]]
def _queryset(self, view):
assert (
hasattr(view, "get_queryset") or getattr(view, "queryset", None) is not None
), (
"Cannot apply {} on a view that does not set "
"`.queryset` or have a `.get_queryset()` method."
).format(
self.__class__.__name__
)
if hasattr(view, "get_queryset"):
queryset = view.get_queryset()
assert queryset is not None, "{}.get_queryset() returned None".format(
view.__class__.__name__
)
return queryset
return view.queryset
class IsAuthenticatePermission(BasePermissions):
def has_permission(self, request, view):
# Workaround to ensure DjangoModelPermissions are not applied
# to the root view when using DefaultRouter.
if getattr(view, "_ignore_model_permissions", False):
return True
validate_user = self.authenticate(request)
request.user = validate_user
if not validate_user or (
not validate_user.is_authenticated and self.authenticated_users_only
):
return False
return True if validate_user else False
class ModelPermission(BasePermissions):
def has_permission(self, request, view):
# Workaround to ensure DjangoModelPermissions are not applied
# to the root view when using DefaultRouter.
if getattr(view, "_ignore_model_permissions", False):
return True
validate_user = self.authenticate(request)
request.user = validate_user
if not request.user or (
not request.user.is_authenticated and self.authenticated_users_only
):
return False
if request.user.is_authenticated and request.user.is_superuser:
return True
queryset = self._queryset(view)
perms = self.get_required_permissions(request.method, queryset.model)
return request.user.has_perms(perms)
class AdminPermission(permissions.BasePermission):
def has_admin_permission(self, request):
if request.user and request.user.is_authenticated and request.user.is_superuser:
return True
return False
After creating the file defined in a setting like this:
"DEFAULT_PERMISSION_CLASSES": (
"your_file_path"
),