Django Rest Framework API Authentication Test - django

I'm writing tests to check if the Authenticated users have access to the API Endpoints.
On my test settings, I have set the defaults for Rest Framework Authentication and Permissions Classes. The default setting is that everyone has to be authenticated to access the API.
REST_FRAMEWORK = {
'DEFAULT_AUTHENTICATION_CLASSES': (
'rest_framework.authentication.BasicAuthentication',
),
'DEFAULT_PERMISSION_CLASSES': (
'rest_framework.permissions.IsAuthenticated',
)}
This is the function which is failing (and all others). Here, I create a user object with a custom UserFactory which is setting a default email and password for each user created. Then I use the APIClient with basic authentication to log in. I'm following the official Django Rest Framework Documentation
def test_retrieve(self):
user = UserFactory.create()
client = APIClient()
client.login(email=user.email, password=user.password)
entity = AttributeChoiceFactory.create()
response = self.get(retrieve=entity.pk)
self.assertEqual(response.status_code, status.HTTP_200_OK)
item = json.loads(response.content)
self.assertEqual(type(item), dict)
self.assertEqual(item['pk'], entity.pk)
self.assertItemsEqual(AttributeChoiceSerializer.Meta.fields, item.keys())
The test fails with Not Authorized Status Code AssertionError: 401 != 200

The problem lies on this line: response = self.get(retrieve=entity.pk)
Since you are using client to login, you must continue to use it to send requests: response = client.get(retrieve=entity.pk)

Related

Replacing Basic Auth in Django Rest Framework

I currently have Django basic auth setup with Knox token authentication. Basic Auth doesn't seem sufficient for production work, so I want to replace that. Does Django have another password-based authentication_class that I can easily replace BasicAuthentication with, or is this a more involved process? If so, where do I start?
my login api view:
class UserLoginView(GenericAPIView):
serializer_class = UserOrganizationSerializer
authentication_classes = (BasicAuthentication,)
permission_classes = (IsAuthenticated,)
def post(self, request):
"""User login with username and password."""
token = AuthToken.objects.create(request.user)
return Response({
'user': self.get_serializer(request.user).data,
'token': token
})
my default authentication classes:
REST_FRAMEWORK = {
'DEFAULT_PERMISSION_CLASSES': [],
'DEFAULT_AUTHENTICATION_CLASSES': [
'rest_framework.authentication.SessionAuthentication',
'rest_framework.authentication.BasicAuthentication',
],
One very common way is to use a JSON Web Token (JWT). The basic package is django-rest-framework-jwt. The instructions are pretty clear in the documentation, but here is an overview:
$> pip install djangorestframework-jwt
REST_FRAMEWORK = {
'DEFAULT_AUTHENTICATION_CLASSES': (
'rest_framework_jwt.authentication.JSONWebTokenAuthentication',
...
),
Add the URL patterns
url_patterns = [
path('jwt/', obtain_jwt_token, name='jwt'),
...
]
Get a token (using the excellent httpie utility)
http post localhost:8000/api/jwt/ username=u password=p
{
"token": "REALLY-LONG-TOKEN"
}
Use that token to make a request:
http get localhost:8000/api/some-endpoint/ Authorization:"JWT REALLY-LONG_TOKEN"
Some Notes
JWT tokens are meant to be decodable by the client. They are protected by a signature, so they can't be modified
You can decode the token (online) and see the expiration time & other data
Tokens can be refreshed or verified through other urls provided by the package
Eventually refreshing will fail and the user will need to login again. This timespan is configurable (see my response to this other question)

CSRF is only checked when authenticated in DRF?

TLDR; It seems that my POSTs (to DRF endpoints) are only CSRF protected, if the client has an authenticated session. This is wrong, and leaves the application option to login CSRF attacks. How can I fix this?
I'm starting to build a django rest framework API for a ReactJS frontend, and we want everything, including the authentication, to be handled via API. We are using SessionAuthentication.
If I have an authenticated session, then CSRF works entirely as expected (when auth'd the client should have a CSRF cookie set, and this needs to be paired with the csrfmiddlewaretoken in the POST data).
However, when not authenticated, no POSTs seem to be subject to CSRF checks. Including the (basic) login APIView that has been created. This leaves the site vulnerable to login CSRF exploits.
Does anyone know how to enforce CSRF checks even on unathenticated sessions? and/or how DRF seems to bypass CSRF checks for login?
Below is my rough setup ...
settings.py:
REST_FRAMEWORK = {
'DEFAULT_AUTHENTICATION_CLASSES': [
'rest_framework.authentication.SessionAuthentication',
],
'DEFAULT_PERMISSION_CLASSES': [
'rest_framework.permissions.IsAuthenticated',
],
}
views.py:
class Login(APIView):
permission_classes = (permissions.AllowAny,)
#method_decorator(csrf_protect) # shouldn't be needed
def post(self, request, format=None):
user = authenticate(
request,
username=request.POST['username'],
password=request.POST['password']
)
# ... perform login logic ...
def get(self, request, format=None):
"""
Client must GET the login to obtain CSRF token
"""
# Force generation of CSRF token so that it's set in the client
get_token(request)
return Response(None)
urls.py:
urlpatterns = [
url(r'^login/$', views.Login.as_view(), name='login'),
]
expected behaviour:
login_url = reverse('login')
login_details = {
'username': self.user.email,
'password': self.password,
}
client = APIClient(enforce_csrf_checks=True)
# Try to just POST to a CSRF protected view with no CSRF
response = client.post(reverse('login'), login_details)
# response status should be 403 Missing or incorrect CSRF
# GET the login API first to obtain CSRF
client.get(reverse('login'))
login_details['csrfmiddlewaretoken'] = client.cookies.get('csrftoken').value
# Now POST to the login API with the CSRF cookie and CSRF token in the POST data
response = client.post(reverse('login'), login_details)
# response status should now be 200 (and a newly rotated CSRF token delivered)
actual behaviour:
client = APIClient(enforce_csrf_checks=True)
# Try to just to a CSRF protected view with no CSRF
response = client.post(reverse('login'), login_details)
# BROKEN: response status is 200, client is now logged in
# Post to the exact same view again, still with no CSRF
response = client.post(reverse('login'), login_details)
# response status is now 403
# BROKEN: This prooves that this view is protected against CSRF, but ONLY for authenticated sessions.
Django REST Framework is disabling CSRF token requirement when using SessionAuthentication and user is not authenticated. This is by design to not mess up other authentication method that don't require CSRF authentication (because they're not based on cookies) and you should ensure by yourself that CSRF is validated on login request and it is mentioned in last paragraph of SessionAuthentication documentation. It is advised to either use non-API login process or ensure that API-based login process is fully protected.
You can check how DRFs SessionAuthentication is enforcing CSRF validation when you are logged in and base your view on that.
You can create a child class of APIView that forces CSRF.
from rest_framework import views
class ForceCRSFAPIView(views.APIView):
#classmethod
def as_view(cls, **initkwargs):
# Force enables CSRF protection. This is needed for unauthenticated API endpoints
# because DjangoRestFramework relies on SessionAuthentication for CSRF validation
view = super().as_view(**initkwargs)
view.csrf_exempt = False
return view
Then all you need to do is change your login view to descend from this
class Login(ForceCRSFAPIView)
# ...

Ionic Google social authentication to Django Rest Framework backend

I am trying to get social authentication working for my mobile app (an Ionic app on Android). Django rest framework backend with rest_framework_jwt, social_django, and rest_social_auth.
On my Ionic app I was using satellizer.js, however, I can't use InAppBrowser so now I am trying to do the following with cordova-plugin-googleplus:
Step#1 (On client/app)
if (provider == 'google') {
// Use Google API Natively to get tokens and user info
window.plugins.googleplus.login(
{
// TODO Get the WebClient from App settings
'webClientId': '[*.myclientid]', // optional clientId of your Web application from Credentials settings of your project - On Android, this MUST be included to get an idToken. On iOS, it is not required.
'offline': true, // optional, but requires the webClientId - if set to true the plugin will also return a serverAuthCode, which can be used to grant offline access to a non-Google server
}) ................
Result: This gets me a valid response with both a idToken, serverAuthCode, and a userId.
Step#2
I am not sure what the next step is. Originally, I was going to try using Django rest_social_auth to do the following from my client/app:
POST /api/login/social/
with data (json)
provider=google&code=ASLKDJASLDKJASLD
Which was supposed to return a JWT token (from my understanding of the docs), however, it is not passing the JWTAuthMixin as there is no value returned from a call to get_authorization_header(request).split() in that Mixin. These means that nothing is returned to my client/app except a 400 error.
Am I supposed to be adding a header to my Ionic app POST when passing my idToken or serverAuthCode? Or am I on the wrong side of the tracks...
Are there any implementation recommendations for this auth flow?
So far I did the following and it works.
1. On app/client
(The client uses satellizer.js and the cordova-plugin-googleplus)
if (provider == 'google') {
// Use Google API Natively to get tokens and user info
window.plugins.googleplus.login(
{
// TODO Get the WebClient from App settings
'webClientId': '*[googleclientid]*.apps.googleusercontent.com',
'offline': true
},
function (obj) {
$http.post(SERVER.url + '[MY BACKEND URL]' + '/google-oauth2/', {code: obj.idToken, servAuthCode: obj.serverAuthCode})
.success(function(data){
$auth.setToken(data.jwt_token);
/.. Do something ../
})
.error(function(data){
console.log("There was an error" + JSON.stringify(data));
});
},
function (msg) {
// TODO Set Error states
console.error('error: ' + msg);
}
);
}
Summary
The app calls the Google plus API googleplus.login method (sending my webClientId)
I post the resulting idToken and serverAuthCode obtained from google after login to my Django backend.
2. My backend methods
URL
My app/client hits the url(r'^[MY BACKEND URL]/(?P<backend>[\w-]+)/$', ObtainAuthToken.as_view(), ),
View
This calls the following view and functions:
class ObtainAuthToken(APIView):
permission_classes = (AllowAny,)
def post(self, request, backend):
data = request.data
user_tokenID = data['code']
server_auth_code = data['servAuthCode']
if user_tokenID and server_auth_code and verify_google_user_token_ID(user_tokenID):
# Get Google OAuth credentials for the verified GOOGLE user.
credentials = settings.GOOGLE_FLOW.step2_exchange(server_auth_code)
# Here we call PSA to authenticate like we would if we used PSA on server side.
user = register_by_access_token(request, backend, token=credentials.access_token)
# If user is active we get or create the REST token and send it back with user data
if user and user.is_active:
# Generate JWT token for user and pass back to client
jwt_payload_handler = api_settings.JWT_PAYLOAD_HANDLER
jwt_encode_handler = api_settings.JWT_ENCODE_HANDLER
payload = jwt_payload_handler(user)
token = jwt_encode_handler(payload)
return JsonResponse({'id': user.id, 'name': user.username, 'jwt_token': token})
return JsonResponse({'status':'false','error':'Bad Credentials, check the Access Token and/or the UID'},
status=403)
def verify_google_user_token_ID(user_tokenID):
try:
google_http_request = google.auth.transport.requests.Request()
idinfo = verify_token(user_tokenID, request=google_http_request,
audience=settings.SOCIAL_AUTH_GOOGLE_OAUTH2_FULL_KEY)
# Or, if multiple clients access the backend server:
if idinfo['aud'] not in [settings.GOOGLE_APP_ID_ANDROID, settings.GOOGLE_APP_ID_WEB]:
raise crypt.AppIdentityError("Unrecognized client.")
if idinfo['iss'] not in ['accounts.google.com', 'https://accounts.google.com']:
raise crypt.AppIdentityError("Wrong issuer.")
return True
except crypt.AppIdentityError as e:
# Invalid token
return False
#psa('social:complete')
def register_by_access_token(request, backend, token):
backend = social_core.backends.google.GoogleOAuth2()
user = backend.do_auth(access_token=token, backend=backend)
if user:
return user
else:
return None
3. Back on the client
My client then looks at the response and takes the returned JWT and loads it to memory with $auth.setToken(data.jwt_token);
I think this works for now, but I still have to deal with token refresh and revocation etc.

Django REST Framework: using TokenAuthentication with browsable API

I'm following the DRF docs to setup TokenAuthentication, and can't get it working with the browsable API. I believe I've added the proper lines in settings.py:
REST_FRAMEWORK = {
'DEFAULT_AUTHENTICATION_CLASSES': (
'rest_framework.authentication.TokenAuthentication',
),
INSTALLED_APPS = (
...
'rest_framework',
'rest_framework.authtoken',
...
As well as generated tokens for existing users with the code snippet from the docs. I can see tokens for each user if I query the authtoken_token table, so I know they exist.
Everytime I try to log in to the browsable API, I get the following content returned:
HTTP 401 Unauthorized
Allow: GET, HEAD, OPTIONS
Content-Type: application/json
Vary: Accept
WWW-Authenticate: Token
{
"detail": "Authentication credentials were not provided."
}
So it appears to be attempting Token authentication, but this message is a little odd. When I enter an incorrect password, I get the 'enter a correct password' message on the login form. When I enter the correct password, it appears to login, but takes me to the API root with the above message, and displays "Log In" on the top menu, rather than the username.
Could this be related to my custom user model somehow? Or could it be due to the fact that I'm currently developing with the dev server, which doesn't support https- the DRF docs mention needing HTTPS with TokenAuthentication, though I wasn't sure if that was a best practice or actually required.
You can't use the browsable api with TokenAuthentication. You have to add SessionAuthtication to your settings (http://www.django-rest-framework.org/api-guide/authentication/#sessionauthentication):
REST_FRAMEWORK = {
'DEFAULT_AUTHENTICATION_CLASSES': (
'rest_framework.authentication.TokenAuthentication',
'rest_framework.authentication.SessionAuthentication',
),
You can use a browser plugin to set token in the header. I'm using Modheader which is free.
The example of setting the header:
I wrote a blog post on how this can be done: link to post.
I like this solution because you don't need to change the auth classes.
I did:
REST_FRAMEWORK = {
'DEFAULT_AUTHENTICATION_CLASSES': (
'rest_framework.authentication.TokenAuthentication',
'rest_framework.authentication.SessionAuthentication',
),
and I added a custom auth class in api.py
class CustomAuthToken(ObtainAuthToken):
authentication_classes = [TokenAuthentication]
def post(self, request, *args, **kwargs):
...
return Response({...})
See https://www.django-rest-framework.org/api-guide/authentication/#by-exposing-an-api-endpoint

django-rest-framework returning 403 response on POST, PUT, DELETE despite AllowAny permissions

I'm using a django-oneall to allow social login session authentication on my site. While it isn't one of the suggested auth providers for django-rest-framework, rest_framework.authentication.SessionAuthentication uses django's default session authentication. so I thought it should be fairly simple to integrate.
On the permissions side, ultimately I'll use IsAdmin, but for development purposes, I just had it set to IsAuthenticated. When that returning 403s, I relaxed the permissions to AllowAny, but still no dice. Here's my rest framework config:
settings.py
REST_FRAMEWORK = {
'DEFAULT_AUTHENTICATION_CLASSES': (
'rest_framework.authentication.SessionAuthentication',
),
'DEFAULT_PERMISSION_CLASSES': (
'rest_framework.permissions.AllowAny',
# 'rest_framework.permissions.IsAuthenticated',
# 'rest_framework.permissions.IsAdminUser',
),
'PAGE_SIZE': 100,
'DEFAULT_FILTER_BACKENDS': (
'rest_framework.filters.DjangoFilterBackend',
),
}
EDIT:
I got this working based on the answer below. It turns out that rest_framework expects both the csrftoken cookie and a a X-CSRFToken Header of the same value, I setup my front-end code to send that header for all ajax requests and everything worked fine.
Django REST Framework returns status code 403 under a couple of relevant circumstances:
When you don't have the required permission level (e.g. making an API request as an unauthenticated user when DEFAULT_PERMISSION_CLASSES is ('rest_framework.permissions.IsAuthenticated',).
When you doing an unsafe request type (POST, PUT, PATCH or DELETE - a request that should have side effects), you are using rest_framework.authentication.SessionAuthentication and you've not included your CSRFToken in the requeset.
When you are doing an unsafe request type and the CSRFToken you've included is no longer valid.
I'm going to make a few demo requests against a test API to give an example of each to help you diagnose which issue you are having and show how to resolve it. I'll be using the requests library.
The test API
I set up a very simple DRF API with a single model, Life, that contains a single field (answer, with a default value of 42). Everything from here on out is pretty straight forward; I set up a ModelSerializer - LifeSerializer, a ModelViewSet - LifeViewSet, and a DefaultRouter on the /life URL route. I've configured DRF to require user's be authenticated to use the API and to use SessionAuthentication.
Hitting the API
import json
import requests
response = requests.get('http://localhost:8000/life/1/')
# prints (403, '{"detail":"Authentication credentials were not provided."}')
print response.status_code, response.content
my_session_id = 'mph3eugf0gh5hyzc8glvrt79r2sd6xu6'
cookies = {}
cookies['sessionid'] = my_session_id
response = requests.get('http://localhost:8000/life/1/',
cookies=cookies)
# prints (200, '{"id":1,"answer":42}')
print response.status_code, response.content
data = json.dumps({'answer': 24})
headers = {'content-type': 'application/json'}
response = requests.put('http://localhost:8000/life/1/',
data=data, headers=headers,
cookies=cookies)
# prints (403, '{"detail":"CSRF Failed: CSRF cookie not set."}')
print response.status_code, response.content
# Let's grab a valid csrftoken
html_response = requests.get('http://localhost:8000/life/1/',
headers={'accept': 'text/html'},
cookies=cookies)
cookies['csrftoken'] = html_response.cookies['csrftoken']
response = requests.put('http://localhost:8000/life/1/',
data=data, headers=headers,
cookies=cookies)
# prints (403, '{"detail":"CSRF Failed: CSRF token missing or incorrect."}')
print response.status_code, response.content
headers['X-CSRFToken'] = cookies['csrftoken']
response = requests.put('http://localhost:8000/life/1/',
data=data, headers=headers,
cookies=cookies)
# prints (200, '{"id":1,"answer":24}')
print response.status_code, response.content
Just for anyone that might find the same problem.
If you are using viewsets without routers like:
user_list = UserViewSet.as_view({'get': 'list'})
user_detail = UserViewSet.as_view({'get': 'retrieve'})
Django Rest framework will return 403 unless you define permission_classes at a class level:
class UserViewSet(viewsets.ModelViewSet):
"""
A viewset for viewing and editing user instances.
"""
permission_classes= YourPermisionClass
Hope it helps!
For completeness sake, there is one more circumstance under which DRF returns code 403: if you forget to add as_view() to the view declaration in your urls.py file. Just happened to me, and I spent hours until I found where the issue was, so maybe this addition can save some time for someone.
For those that aren't able to even access their csrftoken from Javascript:
In my case I wasn't able to get the csrftoken from my Javascript code to be able to set it in my ajax POST. It always printed null. I finally discovered that the django CSRF_COOKIE_HTTPONLY environment variable was set to True.
From the Django Documentation
CSRF_COOKIE_HTTPONLY: If this is set to True, client-side JavaScript will not be able to access the CSRF cookie."
Changing CSRF_COOKIE_HTTPONLY to False allowed me to finally get the csrftoken.
https://docs.djangoproject.com/en/4.1/ref/settings/#std-setting-CSRF_COOKIE_HTTPONLY
One more situation that someone may find is that you get a 403 error on an AllowAny route when you pass an token as null in the "Authorization" header in your request. For example, you may want to allow anyone to use the route but also want to know if the person that used the route is an authenticated user.
E.g.
if (token) {
headers = {
"Content-Type": "application/json",
"Authorization": "Token " + token
}
} else {
headers = {
"Content-Type": "application/json"
}
}