I am using Django REST Framework with simple JWT, and I don't like how permission classes generate a response without giving me the final say on what gets sent to the client. With other class-based views not restricting permissions (login, registration, etc.), I have control over how I handle exceptions, and I can choose how the response data is structured.
However, anytime I introduce permission classes, undesired behavior occurs. My desired structure is best represented in my LoginView (see try/except block):
NON_FIELD_ERRORS_KEY = settings.REST_FRAMEWORK['NON_FIELD_ERRORS_KEY']
class LoginView(GenericAPIView):
"""
View for taking in an existing user's credentials and authorizing them if valid or denying access if invalid.
"""
serializer_class = LoginSerializer
def post(self, request):
"""
POST method for taking a token from a query string, checking if it is valid, and logging in the user if valid, or returning an error response if invalid.
"""
serializer = self.serializer_class(data=request.data)
try:
serializer.is_valid(raise_exception=True)
except AuthenticationFailed as e:
return Response({NON_FIELD_ERRORS_KEY: [e.detail]}, status=e.status_code)
return Response(serializer.data, status=status.HTTP_200_OK)
However, what happens when I try to define a view using a permission class?
class LogoutView(GenericAPIView):
"""
View for taking in a user's access token and logging them out.
"""
serializer_class = LogoutSerializer
permission_classes = [IsAuthenticated]
def post(self, request):
"""
POST method for taking a token from a request body, checking if it is valid, and logging out the user if valid, or returning an error response if invalid.
"""
access = request.META.get('HTTP_AUTHORIZATION', '')
serializer = self.serializer_class(data={'access': access})
try:
serializer.is_valid(raise_exception=True)
except AuthenticationFailed as e:
return Response({NON_FIELD_ERRORS_KEY: [e.detail]}, status=e.status_code)
return Response(serializer.save(), status=status.HTTP_205_RESET_CONTENT)
When I go to test this, requests with an invalid Authorization header are handled outside the scope of post(), so execution never reaches the method at all. Instead, I am forced to deal with a response that is inconsistent with the rest of my project. Here's an example:
# Desired output
{
'errors': [
ErrorDetail(string='Given token not valid for any token type', code='token_not_valid')
]
}
# Actual output
{
'detail': ErrorDetail(string='Given token not valid for any token type', code='token_not_valid'),
'code': ErrorDetail(string='token_not_valid', code='token_not_valid'),
'messages': [
{
'token_class': ErrorDetail(string='AccessToken', code='token_not_valid'),
'token_type': ErrorDetail(string='access', code='token_not_valid'),
'message': ErrorDetail(string='Token is invalid or expired', code='token_not_valid')
}
]
}
Is there a simple way to change how these responses are formatted?
After stepping through the code, I found that the exceptions with which I was concerned were being raised in the last commented section of APIView.initial.
# rest_framework/views.py (lines 399-416)
def initial(self, request, *args, **kwargs):
"""
Runs anything that needs to occur prior to calling the method handler.
"""
self.format_kwarg = self.get_format_suffix(**kwargs)
# Perform content negotiation and store the accepted info on the request
neg = self.perform_content_negotiation(request)
request.accepted_renderer, request.accepted_media_type = neg
# Determine the API version, if versioning is in use.
version, scheme = self.determine_version(request, *args, **kwargs)
request.version, request.versioning_scheme = version, scheme
# Ensure that the incoming request is permitted
self.perform_authentication(request)
self.check_permissions(request)
self.check_throttles(request)
To generate a custom response, either create a custom implementation of APIView with an overloaded initial() method, or override the method in specific instances of APIView (or descendants such as GenericAPIView).
# my_app/views.py
def initial(self, request, *args, **kwargs):
"""
This method overrides the default APIView method so exceptions can be handled.
"""
try:
super().initial(request, *args, **kwargs)
except (AuthenticationFailed, InvalidToken) as exc:
raise AuthenticationFailed(
{NON_FIELD_ERRORS_KEY: [_('The provided token is invalid.')]},
'invalid')
except NotAuthenticated as exc:
raise NotAuthenticated(
{
NON_FIELD_ERRORS_KEY:
[_('Authentication credentials were not provided.')]
}, 'not_authenticated')
Related
I want to send the SimpleJWT access and refresh tokens through HttpOnly cookie. I have customized the claim. I have defined a post() method in the MyObtainTokenPairView(TokenObtainPairView) in which I am setting the cookie. This is my code:
from .models import CustomUser
class MyObtainTokenPairView(TokenObtainPairView):
permission_classes = (permissions.AllowAny,)
serializer_class = MyTokenObtainPairSerializer
def post(self, request, *args, **kwargs):
serializer = self.serializer_class()
response = Response()
tokens = serializer.get_token(CustomUser)
access = tokens.access
response.set_cookie('token', access, httponly=True)
return response
It's returning this error:
AttributeError: 'RefreshToken' object has no attribute 'access'
The serializer:
class MyTokenObtainPairSerializer(TokenObtainPairSerializer):
#classmethod
def get_token(cls, user):
print(type(user))
token = super().get_token(user)
token['email'] = user.email
return token
But it's just not working. I think I should not define a post() method here like this. I think if I can only return the value of the get_token() function in the serializer, I could set it as HttpOnly cookie. But, I don't know how to do that.
How do I set the access and refresh tokens in the HttpOnly cookie?
EDIT:
I made these changes following anowlinorbit's answer:
I changed my serializer to this:
class MyTokenObtainPairSerializer(TokenObtainPairSerializer):
def validate(self, attrs):
attrs = super().validate(attrs)
token = self.get_token(self.user)
token["email"] = self.user.email
return token
Since this token contains the refresh token by default therefore, I decided that returning only this token would provide both access and refresh token.
If I add anything like token["access"] = str(token.access_token) it would just add the access token string inside the refresh token string, which it already contains.
But again in the view, I could not find how to get the refresh token. I could not get it using serializer.validated_data.get('refresh', None) since now I am returning the token from serializer which contains everything.
I changed my view to this:
class MyObtainTokenPairView(TokenObtainPairView):
permission_classes = (permissions.AllowAny,)
serializer_class = MyTokenObtainPairSerializer
def post(self, request, *args, **kwargs):
response = super().post(request, *args, **kwargs)
response.set_cookie('token', token, httponly=True)
return response
Now it's saying:
NameError: name 'token' is not defined
What's wrong here? In the view I want to get the token returned from serializer, then get the acces token using token.access_token and set both refresh and access as cookies.
I would leave .get_token() alone and instead focus on .validate(). In your MyTokenObtainPairSerializer I would remove your changes to .get_token() and add the following
def validate(self, attrs):
data = super().validate(attrs)
refresh = self.get_token(self.user)
data["refresh"] = str(refresh) # comment out if you don't want this
data["access"] = str(refresh.access_token)
data["email"] = self.user.email
""" Add extra responses here should you wish
data["userid"] = self.user.id
data["my_favourite_bird"] = "Jack Snipe"
"""
return data
It is by using the .validate() method with which you can choose which data you wish to return from the serializer object's validated_data attribute. N.B. I have also included the refresh token in the data which the serializer returns. Having both a refresh and access token is important. If a user doesn't have the refresh token they will have to login again when the access token expires. The refresh token allows them to get a new access token without having to login again.
If for whatever reason you don't want the refresh token, remove it from your validate() serializer method and adjust the view accordingly.
In this post method, we validate the serializer and access its validated data.
def post(self, request, *args, **kwargs):
# you need to instantiate the serializer with the request data
serializer = self.serializer(data=request.data)
# you must call .is_valid() before accessing validated_data
serializer.is_valid(raise_exception=True)
# get access and refresh tokens to do what you like with
access = serializer.validated_data.get("access", None)
refresh = serializer.validated_data.get("refresh", None)
email = serializer.validated_data.get("email", None)
# build your response and set cookie
if access is not None:
response = Response({"access": access, "refresh": refresh, "email": email}, status=200)
response.set_cookie('token', access, httponly=True)
response.set_cookie('refresh', refresh, httponly=True)
response.set_cookie('email', email, httponly=True)
return response
return Response({"Error": "Something went wrong", status=400)
If you didn't want the refresh token, you would remove the line beginning refresh = and remove the line where you add the refresh cookie.
class MyObtainTokenPairView(TokenObtainPairView):
permission_classes = (permissions.AllowAny,)
serializer_class = MyTokenObtainPairSerializer
def post(self, request, *args, **kwargs):
response = super().post(request, *args, **kwargs)
token = response.data["access"] # NEW LINE
response.set_cookie('token', token, httponly=True)
return response
You can solve it by setting the token as the "access" from the response data.
Btw, did you find any better solution for this?
I'm starting to dive into DRF a little deeper of late, and I was wondering I would like to start customising the error messaging that gets return via the API for incorrect permissions, I'd like to wrap a little extra detail.
For example, if authentication credentials were not provided for an endpoint that is permission restricted, the API returns:
{
"detail": "Authentication credentials were not provided."
}
Which comes from line 171 from the rest_framework.exceptions: https://github.com/encode/django-rest-framework/blob/master/rest_framework/exceptions.py. Really, I'd like this to be consistent with the
{
"success": false,
"message": "Authentication credentials were not provided.",
"data": null
}
So, I assume I now need to begin customising my own exceptions.
How best should I go about doing this?
Perhaps it has some tie in with default_error_messages = {} inside the serializer ...
You can override DRF's default exception handler and JSON parser on your settings.py:
REST_FRAMEWORK = {
...
'EXCEPTION_HANDLER': 'helpers.exceptions.custom_exception_handler',
'DEFAULT_RENDERER_CLASSES': [
'helpers.renderers.JSONRenderer',
'rest_framework.renderers.BrowsableAPIRenderer',
]
}
And then it's just a matter of customizing how to handle your exceptions and how to render the responses:
def custom_exception_handler(exc, context):
# Call REST framework's default exception handler first,
# to get the standard error response.
response = exception_handler(exc, context)
# Customize your exception handling here
return response
And you can use the custom JSON renderer in case you need to do any extra formatting on the response, in my case I had to add a "status_code" to the payload:
class JSONRenderer(BaseJsonRenderer):
def render(self, data, accepted_media_type=None, renderer_context=None):
"""
Render `data` into JSON, returning a bytestring.
"""
<Base code from the original class...>
response = renderer_context.get('response')
if response and 200 <= response.status_code <= 299 and 'status_code' not in response.data:
response.data = Errors.success(response.data)
<Base code from the original class...>
My Errors.success(response.data) was just a simpler way to merge the success status code to the data.
There is a decorator solution that creates custom Response on each type of your exceptions:
# project/api/exceptions.py
from functools import wraps
from rest_framework import status
def handle_exceptions(func):
#wraps(func)
def wrapper(*args, **kwargs):
try:
return func(*args, **kwargs)
except AuthCredentialsError as exc:
return Response(
{"message": exc.message},
status=status.HTTP_405_METHOD_NOT_ALLOWED,
)
return wrapper
# project/api/your_code.py
from project.api.exceptions import handle_exceptions
class SomeViewSet():
#handle_exceptions
def create(self, request, *args, **kwargs):
raise AuthCredentialsError("Authentication credentials were not provided")
I'm continuing my journey of testing my Django Rest Framework application as I add new views and more functionality. I must admit, at this stage, I'm finding testing harder than actually coding and building my app. I feel that there are far fewer resources on testing DRF available than there are resources talking about building a REST framework with DRF. C'est la vie, though, I soldier on.
My issue that I'm currently facing is that I'm receiving a 403 error when testing one of my DRF ViewSets. I can confirm that the view, and its permissions work fine when using the browser or a regular python script to access the endpoint.
Let's start with the model that is used in my ViewSet
class QuizTracking(models.Model):
case_attempt = models.ForeignKey(CaseAttempt, on_delete=models.CASCADE)
user = models.ForeignKey(CustomUser, on_delete=models.CASCADE)
answer = models.ForeignKey(Answer, on_delete=models.CASCADE)
timestamp = models.DateTimeField(auto_now_add=True)
Of note here is that there is a FK to a user. This is used when determining permissions.
Here is my test function. I have not included code for the entire class for the sake of brevity.
def test_question_retrieve(self):
"""
Check that quiz tracking/ID returns a 200 OK and the content is correct
"""
jim = User(username='jimmy', password='monkey123', email='jimmy#jim.com')
jim.save()
quiz_tracking = QuizTracking(answer=self.answer, case_attempt=self.case_attempt, user=jim)
quiz_tracking.save()
request = self.factory.get(f'/api/v1/progress/quiz-tracking/{quiz_tracking.id}')
# How do I refernce my custom permission using Permission.objects.get() ?
# permission = Permission.objects.get()
# jim.user_permissions.add(permission)
self.test_user.refresh_from_db()
force_authenticate(request, user=jim)
response = self.quiz_detail_view(request, pk=quiz_tracking.id)
print(response.data)
print(jim.id)
print(quiz_tracking.user.id)
self.assertContains(response, 'answer')
self.assertEqual(response.status_code, status.HTTP_200_OK)
In the above code, I define a user, jim and a quiz_tracking object owned by jim.
I build my request, force_authenticate the requst and the execute my request and store the response in response.
The interesting things to note here are:
- jim.id and quiz_tracking.user.id are the same value
- I receive a 403 response with
{'detail': ErrorDetail(string='You do not have permission to perform this action.', code='permission_denied')}
You may have noticed that I have commented out permission = Permission.objects.get() My understanding is that I need to pass this my Permission Class, which in my case is IsUser. However, there is no record of this in my DB and hence Permission.objects.get('IsUSer') call fails.
So my questions are as follows:
- How do I authenticate my request so that I receive a 200 OK?
- Do I need to add a permission to my user in my tests, and if so, which permission and with what syntax?
Below is my view and below that is my custom permission file defining IsUser
class QuickTrackingViewSet(viewsets.ModelViewSet):
serializer_class = QuizTrackingSerializser
def get_queryset(self):
return QuizTracking.objects.all().filter(user=self.request.user)
def get_permissions(self):
if self.action == 'list':
self.permission_classes = [IsUser, ]
elif self.action == 'retrieve':
self.permission_classes = [IsUser, ]
return super(self.__class__, self).get_permissions()
N.B. If I comment out def get_permissions(), then my test passes without problem.
My custom permission.py
from rest_framework.permissions import BasePermission
class IsSuperUser(BasePermission):
def has_permission(self, request, view):
return request.user and request.user.is_superuser
class IsUser(BasePermission):
def has_object_permission(self, request, view, obj):
if request.user:
if request.user.is_superuser:
return True
else:
return obj == request.user
else:
return False
Cheers,
C
It seems that the problem is your permission IsUser.
You compare QuizTracking instance with user instance.
change this line
return obj == request.user
to
return obj.user.id == request.user.id
One off topic suggestion
You can write your IsUser class in the following way which is easier to read
class IsUser(BasePermission):
def has_permission(self, request, view):
return request.user and request.user.is_authenticated
def has_object_permission(self, request, view, obj):
return request.user.is_superuser or obj.user.id == request.user.id
Note that has_object_permission is only called if the view level has_permission returns True.
For the following piece of code, I would like to return a boolean corresponding to whether the user was authenticated or not.
class UserAuthenticatedView(APIView):
authentication_classes = (TokenAuthentication,)
permission_classes = (AllowAny,)
def get(self, request, format=None):
is_authenticated = request.user.is_authenticated()
resp = {'is_authenticated': is_authenticated}
return Response(resp, content_type="application/json", status=status.HTTP_200_OK)
However, for invalid token, the control is not not even going inside get method due to which I'm not able to customize the response. In such a case I'm getting the response: {'detail': 'invalid token'},
Any idea on how to customize the response for invalid token ?
You can create a CustomTokenAuthentication class and override the authenticate_credentials() method to return the custom response in case of invalid token.
class CustomTokenAuthentication(TokenAuthentication):
def authenticate_credentials(self, key):
try:
token = self.model.objects.select_related('user').get(key=key)
except self.model.DoesNotExist:
# modify the original exception response
raise exceptions.AuthenticationFailed('Custom error message')
if not token.user.is_active:
# can also modify this exception message
raise exceptions.AuthenticationFailed('User inactive or deleted')
return (token.user, token)
After doing this, define this custom token authentication class in your DRF settings or on a per-view/viewset basis.
Another option is to create a custom exception handler. In that, you can check if the exception raised was of type AuthenticationFailed and the exception message is 'invalid token'. There you can modify the exception message (also check this official DRF example).
This worked for me:
Custom Authentication class:
class MyAuthentication(authentication.TokenAuthentication):
def authenticate_credentials(self, key):
try:
token = self.model.objects.select_related('user').get(key=key)
except self.model.DoesNotExist:
return (None, '')
if not token.user.is_active:
raise exceptions.AuthenticationFailed(_('User inactive or deleted.'))
return (token.user, token)
view class:
class UserAuthenticatedView(APIView):
authentication_classes = (MyAuthentication,)
permission_classes = (AllowAny,)
def get(self, request, format=None):
is_authenticated = False
if request.user and request.user.is_authenticated():
is_authenticated = True
resp = {'is_authenticated': is_authenticated}
return Response(resp, content_type="application/json", status=status.HTTP_200_OK)
I am using a custom authentication backend for Django (which runs off couchdb). I have a custom user model.
As part of the login, I am doing a request.user = user and saving the user id in session.
However, on subsequent requests, I am not able to retrieve the request.user. It is always an AnonymousUser. I can, however, retrieve the user id from the session and can confirm that the session cookie is being set correctly.
What am I missing?
I do not want to use a relational db as I want to maintain all my user data in couchdb.
Edit: I have written a class which does not inherit from Django's auth User. It, however, has the username and email attributes. For this reason, my backend does not return a class which derives from auth User.
The request.user is set by the django.contrib.auth.middleware.AuthenticationMiddleware.
Check django/contrib/auth/middleware.py:
class LazyUser(object):
def __get__(self, request, obj_type=None):
if not hasattr(request, '_cached_user'):
from django.contrib.auth import get_user
request._cached_user = get_user(request)
return request._cached_user
class AuthenticationMiddleware(object):
def process_request(self, request):
request.__class__.user = LazyUser()
return None
Then look at the get_user function in django/contrib/auth/__init__.py:
def get_user(request):
from django.contrib.auth.models import AnonymousUser
try:
user_id = request.session[SESSION_KEY]
backend_path = request.session[BACKEND_SESSION_KEY]
backend = load_backend(backend_path)
user = backend.get_user(user_id) or AnonymousUser()
except KeyError:
user = AnonymousUser()
return user
Your backend will need to implement the get_user function.
I too have custom authentication backend and always got AnonymousUser after successful authentication and login. I had the get_user method in my backend. What I was missing was that get_user must get the user by pk only, not by email or whatever your credentials in authenticate are:
class AccountAuthBackend(object):
#staticmethod
def authenticate(email=None, password=None):
try:
user = User.objects.get(email=email)
if user.check_password(password):
return user
except User.DoesNotExist:
return None
#staticmethod
def get_user(id_):
try:
return User.objects.get(pk=id_) # <-- tried to get by email here
except User.DoesNotExist:
return None
Its easy to miss this line in the docs:
The get_user method takes a user_id – which could be a username,
database ID or whatever, but has to be the primary key of your User
object – and returns a User object.
It so happened that email is not primary key in my schema. Hope this saves somebody some time.
You say you've written a custom authentication backend, but in fact what you seem to have written is a complete custom authentication app, which doesn't interface with Django's contrib.auth.
If you want to use a non-relational database for your authentication data, all you need to do is create a class that provides two methods: get_user(user_id) and authenticate(**credentials). See the documentation. Once you have authenticated a user, you simply call Django's normal login methods. There should be no reason to manually set request.user or put anything into the session.
Update after edit That has nothing to do with it. There's no requirement that the user class derives from auth.models.User. You still just need to define a get_user method that will return an instance of your user class.
Please elaborate. If you are using a custom user model (which is different from a custom user PROFILE model), then you are basically on your own and the django.contrib.auth framework can not help you with authentication. If you are writing your own authentication system and are not using django.contrib.auth, then you need to turn that off because it seem to be interfering with your system.
In case you are using an API (Django-rest-framework) and accessing a view using a get, post, etc. methods.
You can get a user by sending the Bearer/JWT token corresponding to that user.
Wrong
# prints Anonymous User
def printUser(request):
print(request.user)
Correct
# using decorators
# prints username of the user
#api_view(['GET']) # or ['POST'] .... according to the requirement
def printUser()
print(request.user)
I had similar problem when I used custom authentication backend. I used field different than the primary key in the method get_user.
It directly solved after using primary key which must be number (not str)
def get_user(self, user_id):
try:
return User.objects.get(pk=user_id) # <-- must be primary key and number
except User.DoesNotExist:
return None
After sending Token using Authorization header, the token will be gotten in dispatch function as bellow:
'''
def dispatch(self, request, *args, **kwargs):
self.args = args
self.kwargs = kwargs
request = self.initialize_request(request, *args, **kwargs)
self.request = request
self.headers = self.default_response_headers # deprecate?
try:
self.initial(request, *args, **kwargs)
# Get the appropriate handler method
if request.method.lower() in self.http_method_names:
handler = getattr(self, request.method.lower(),
self.http_method_not_allowed)
else:
handler = self.http_method_not_allowed
response = handler(request, *args, **kwargs)
except Exception as exc:
response = self.handle_exception(exc)
self.response = self.finalize_response(request, response, *args, **kwargs)
return self.response
So you are using django_role_permission's HasRoleMixin, the dispatch method of this mixin will hide dispatch of the view.
I think that the solution is to redefine the mixin of roles-permissions
user = authenticate(username=username, password=password)
if user is not None:
return render(request, 'home.html',{'user_id':user.id})
Added these in my view
from rest_framework.permissions import IsAuthenticated
from rest_framework.authentication import TokenAuthentication
authentication_classes = (TokenAuthentication,)
permission_classes = (IsAuthenticated,)
and started getting original user