diff --git a/README.md b/README.md index 53e25fc7..3fce8b98 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,100 @@ Few important things: * It can be easily deployed to Heroku. * It comes with an example list API, that uses [`django-filter`](https://django-filter.readthedocs.io/en/stable/) for filtering & pagination from DRF. +## General API Stuff + +### CORS + +The project is running [`django-cors-headers`](https://github.com/adamchainz/django-cors-headers) with the following general configuration: + +```python +CORS_ALLOW_CREDENTIALS = True +CORS_ALLOW_ALL_ORIGINS = True +``` + +For `production.py`, we have the following: + +```python +CORS_ALLOW_ALL_ORIGINS = False +CORS_ORIGIN_WHITELIST = env.list('DJANGO_CORS_ORIGIN_WHITELIST', default=[]) +``` + +### DRF + +We have removed the default authentication classes, since they were causing trouble. + +## Authentication - General + +This project is using the already existing [**cookie-based session authentication**](https://docs.djangoproject.com/en/3.1/topics/auth/default/#how-to-log-a-user-in) in Django: + +1. On successful authentication, Django returns the `sessionid` cookie: + +``` +sessionid=5yic8rov868prmfoin2vhtg4vx35h71p; expires=Tue, 13 Apr 2021 11:17:58 GMT; HttpOnly; Max-Age=1209600; Path=/; SameSite=Lax +``` + +2. When making calls from the frontend, don't forget to **include credentials**. For example, when using `axios`: + +```javascript +axios.get(url, { withCredentials: true }); +axios.post(url, data, { withCredentials: true }); +``` + +3. For convenience, `CSRF_USE_SESSIONS` is set to `True` + +4. Check `config/settings/sessions.py` for all configuration that's related to sessions. + +### DRF & Overriding `SessionAuthentication` + +Since the default implementation of `SessionAuthentication` enforces CSRF check, which is not the desired behavior for our APIs, we've done the following: + +```python +from rest_framework.authentication import SessionAuthentication + + +class CsrfExemptedSessionAuthentication(SessionAuthentication): + """ + DRF SessionAuthentication is enforcing CSRF, which may be problematic. + That's why we want to make sure we are exempting any kind of CSRF checks for APIs. + """ + def enforce_csrf(self, request): + return +``` + +Which is then used to construct an `ApiAuthMixin`, which marks an API that requires authentication: + +```python +from rest_framework.permissions import IsAuthenticated + + +class ApiAuthMixin: + authentication_classes = (CsrfExemptedSessionAuthentication, ) + permission_classes = (IsAuthenticated, ) +``` + +**By default, all APIs are public, unless you add the `ApiAuthMixin`** + +### Cross origin + +We have the following general cases: + +1. The current configuration works out of the box for `localhost` development. +1. If the backend is located on `*.domain.com` and the frontend is located on `*.domain.com`, the configuration is going to work out of the box. +1. If the backend is located on `somedomain.com` and the frontend is located on `anotherdomain.com`, then you'll need to set `SESSION_COOKIE_SAMESITE = 'None'` and `SESSION_COOKIE_SECURE = True` + +### Reading list + +Since cookies can be somewhat elusive, check the following urls: + +1. - It's a good idea to just read every description for `SESSION_*` +1. - It's a good idea to read everything, several times. + +## Authentication APIs + +1. `POST` requires JSON body with `email` and `password`. +1. `GET` returns the current user information, if the request is authenticated (has the corresponding `sessionid` cookie) +1. `GET` or `POST` will remove the `sessionid` cookie, effectively logging you out. + ## Example List API You can find the `UserListApi` in [`styleguide_example/users/apis.py`](https://github.com/HackSoftware/Styleguide-Example/blob/master/styleguide_example/users/apis.py#L12) diff --git a/config/settings/base.py b/config/settings/base.py index ff222fe9..4369fc92 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -12,8 +12,6 @@ import os -from datetime import timedelta - from .env_reader import env # Build paths inside the project like this: os.path.join(BASE_DIR, ...) @@ -29,7 +27,7 @@ # SECURITY WARNING: don't run with debug turned on in production! DEBUG = True -ALLOWED_HOSTS = [] +ALLOWED_HOSTS = ['*'] # Application definition @@ -44,7 +42,8 @@ 'rest_framework', 'django_celery_results', 'django_celery_beat', - 'django_filters' + 'django_filters', + 'corsheaders' ] INSTALLED_APPS = [ @@ -60,6 +59,7 @@ MIDDLEWARE = [ 'django.middleware.security.SecurityMiddleware', + 'corsheaders.middleware.CorsMiddleware', 'whitenoise.middleware.WhiteNoiseMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware', 'django.middleware.common.CommonMiddleware', @@ -153,22 +153,14 @@ STATIC_URL = '/static/' STATICFILES_STORAGE = 'whitenoise.storage.CompressedManifestStaticFilesStorage' - -# https://django-rest-framework-simplejwt.readthedocs.io/en/latest/settings.html -SIMPLE_JWT = { - 'ACCESS_TOKEN_LIFETIME': timedelta(days=30), - 'REFRESH_TOKEN_LIFETIME': timedelta(days=31), - # TODO: Open issue for having a callable with a user here - 'SIGNING_KEY': env('DJANGO_JWT_SIGNING_KEY', default=SECRET_KEY) - # TODO: https://github.com/SimpleJWT/django-rest-framework-simplejwt/pull/157/files - # Add settings for http support -} - REST_FRAMEWORK = { 'EXCEPTION_HANDLER': 'styleguide_example.api.errors.custom_exception_handler', 'DEFAULT_FILTER_BACKENDS': ( 'django_filters.rest_framework.DjangoFilterBackend', ), + 'DEFAULT_AUTHENTICATION_CLASSES': [] } +from .cors import * # noqa +from .sessions import * # noqa from .celery import * # noqa diff --git a/config/settings/cors.py b/config/settings/cors.py new file mode 100644 index 00000000..cb498c99 --- /dev/null +++ b/config/settings/cors.py @@ -0,0 +1,2 @@ +CORS_ALLOW_CREDENTIALS = True +CORS_ALLOW_ALL_ORIGINS = True diff --git a/config/settings/production.py b/config/settings/production.py index 2b10c7d8..f6466d02 100644 --- a/config/settings/production.py +++ b/config/settings/production.py @@ -7,14 +7,15 @@ ALLOWED_HOSTS = env.list('DJANGO_ALLOWED_HOSTS', default=[]) +CORS_ALLOW_ALL_ORIGINS = False +CORS_ORIGIN_WHITELIST = env.list('DJANGO_CORS_ORIGIN_WHITELIST', default=[]) + +SESSION_COOKIE_SECURE = env.bool('DJANGO_SESSION_COOKIE_SECURE', default=True) + # https://docs.djangoproject.com/en/dev/ref/settings/#secure-proxy-ssl-header SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") # https://docs.djangoproject.com/en/dev/ref/settings/#secure-ssl-redirect SECURE_SSL_REDIRECT = env.bool("DJANGO_SECURE_SSL_REDIRECT", default=True) -# https://docs.djangoproject.com/en/dev/ref/settings/#session-cookie-secure -SESSION_COOKIE_SECURE = True -# https://docs.djangoproject.com/en/dev/ref/settings/#csrf-cookie-secure -CSRF_COOKIE_SECURE = True # https://docs.djangoproject.com/en/dev/ref/middleware/#x-content-type-options-nosniff SECURE_CONTENT_TYPE_NOSNIFF = env.bool( "DJANGO_SECURE_CONTENT_TYPE_NOSNIFF", default=True diff --git a/config/settings/sessions.py b/config/settings/sessions.py new file mode 100644 index 00000000..68a353a4 --- /dev/null +++ b/config/settings/sessions.py @@ -0,0 +1,16 @@ +from .env_reader import env + + +""" +Do read: + + 1. https://docs.djangoproject.com/en/3.1/ref/settings/#sessions + 2. https://developer.mozilla.org/en-US/docs/Web/HTTP/Cookies +""" +SESSION_COOKIE_AGE = env.int('DJANGO_SESSION_COOKIE_AGE', default=1209600) # Default - 2 weeks in seconds +SESSION_COOKIE_HTTPONLY = env.bool('DJANGO_SESSION_COOKIE_HTTPONLY', default=True) +SESSION_COOKIE_NAME = env('DJANGO_SESSION_COOKIE_NAME', default='sessionid') +SESSION_COOKIE_SAMESITE = env('DJANGO_SESSION_COOKE_SAMESITE', default='Lax') +SESSION_COOKIE_SECURE = env.bool('DJANGO_SESSION_COOKIE_SECURE', default=False) + +CSRF_USE_SESSIONS = env.bool('DJANGO_CSRF_USE_SESSIONS', default=True) diff --git a/requirements/base.txt b/requirements/base.txt index e6a72ebb..ff8d097f 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -1,8 +1,7 @@ -Django==3.1.6 +Django==3.1.7 django-environ==0.4.5 psycopg2==2.8.5 djangorestframework==3.11.2 -djangorestframework-simplejwt==4.4.0 celery==4.4.6 django-celery-results==1.2.1 @@ -11,3 +10,4 @@ django-celery-beat==2.0.0 whitenoise==5.1.0 django-filter==2.4.0 +django-cors-headers==3.7.0 diff --git a/styleguide_example/api/mixins.py b/styleguide_example/api/mixins.py index 8f9adc0a..2a3444c1 100644 --- a/styleguide_example/api/mixins.py +++ b/styleguide_example/api/mixins.py @@ -2,13 +2,22 @@ from rest_framework import exceptions as rest_exceptions from rest_framework.permissions import IsAuthenticated -from rest_framework_simplejwt.authentication import JWTAuthentication +from rest_framework.authentication import SessionAuthentication from styleguide_example.api.errors import get_error_message +class CsrfExemptedSessionAuthentication(SessionAuthentication): + """ + DRF SessionAuthentication is enforcing CSRF, which may be problematic. + That's why we want to make sure we are exempting any kind of CSRF checks for APIs. + """ + def enforce_csrf(self, request): + return + + class ApiAuthMixin: - authentication_classes = (JWTAuthentication, ) + authentication_classes = (CsrfExemptedSessionAuthentication, ) permission_classes = (IsAuthenticated, ) diff --git a/styleguide_example/authentication/apis.py b/styleguide_example/authentication/apis.py index e1f89c8a..551f68ca 100644 --- a/styleguide_example/authentication/apis.py +++ b/styleguide_example/authentication/apis.py @@ -1,17 +1,51 @@ +from django.contrib.auth import authenticate, login, logout + from rest_framework.views import APIView from rest_framework.response import Response - -from rest_framework_simplejwt.views import ( - TokenObtainPairView, -) +from rest_framework import serializers +from rest_framework import status from styleguide_example.api.mixins import ApiAuthMixin from styleguide_example.users.selectors import user_get_login_data -class UserLoginApi(TokenObtainPairView): - pass +class UserLoginApi(APIView): + """ + Following https://docs.djangoproject.com/en/3.1/topics/auth/default/#how-to-log-a-user-in + """ + class InputSerializer(serializers.Serializer): + email = serializers.EmailField() + password = serializers.CharField() + + def post(self, request): + serializer = self.InputSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + print(request.user) + user = authenticate(request, **serializer.validated_data) + print(user) + + if user is None: + return Response(status=status.HTTP_401_UNAUTHORIZED) + + login(request, user) + + data = user_get_login_data(user=user) + + return Response(data) + + +class UserLogoutApi(APIView): + def get(self, request): + logout(request) + + return Response() + + def post(self, request): + logout(request) + + return Response() class UserMeApi(ApiAuthMixin, APIView): diff --git a/styleguide_example/authentication/urls.py b/styleguide_example/authentication/urls.py index 7c77d339..d8819ed8 100644 --- a/styleguide_example/authentication/urls.py +++ b/styleguide_example/authentication/urls.py @@ -1,6 +1,6 @@ from django.urls import path -from .apis import UserLoginApi, UserMeApi +from .apis import UserLoginApi, UserLogoutApi, UserMeApi urlpatterns = [ path( @@ -8,6 +8,11 @@ UserLoginApi.as_view(), name='login' ), + path( + 'logout/', + UserLogoutApi.as_view(), + name='logout' + ), path( 'me/', UserMeApi.as_view(),