Skip to content

API Login: Session based #17

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 3 commits into from
Mar 30, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 94 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. <https://docs.djangoproject.com/en/3.1/ref/settings/#sessions> - It's a good idea to just read every description for `SESSION_*`
1. <https://developer.mozilla.org/en-US/docs/Web/HTTP/Cookies> - It's a good idea to read everything, several times.

## Authentication APIs

1. `POST` <http://localhost:8000/api/auth/login/> requires JSON body with `email` and `password`.
1. `GET` <http://localhost:8000/api/auth/me/> returns the current user information, if the request is authenticated (has the corresponding `sessionid` cookie)
1. `GET` or `POST` <http://localhost:8000/api/auth/logout/> 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)
Expand Down
22 changes: 7 additions & 15 deletions config/settings/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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, ...)
Expand All @@ -29,7 +27,7 @@
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = True

ALLOWED_HOSTS = []
ALLOWED_HOSTS = ['*']


# Application definition
Expand All @@ -44,7 +42,8 @@
'rest_framework',
'django_celery_results',
'django_celery_beat',
'django_filters'
'django_filters',
'corsheaders'
]

INSTALLED_APPS = [
Expand All @@ -60,6 +59,7 @@

MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'corsheaders.middleware.CorsMiddleware',
'whitenoise.middleware.WhiteNoiseMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware',
Expand Down Expand Up @@ -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
2 changes: 2 additions & 0 deletions config/settings/cors.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
CORS_ALLOW_CREDENTIALS = True
CORS_ALLOW_ALL_ORIGINS = True
9 changes: 5 additions & 4 deletions config/settings/production.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 16 additions & 0 deletions config/settings/sessions.py
Original file line number Diff line number Diff line change
@@ -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)
4 changes: 2 additions & 2 deletions requirements/base.txt
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
13 changes: 11 additions & 2 deletions styleguide_example/api/mixins.py
Original file line number Diff line number Diff line change
Expand Up @@ -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, )


Expand Down
46 changes: 40 additions & 6 deletions styleguide_example/authentication/apis.py
Original file line number Diff line number Diff line change
@@ -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):
Expand Down
7 changes: 6 additions & 1 deletion styleguide_example/authentication/urls.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,18 @@
from django.urls import path

from .apis import UserLoginApi, UserMeApi
from .apis import UserLoginApi, UserLogoutApi, UserMeApi

urlpatterns = [
path(
'login/',
UserLoginApi.as_view(),
name='login'
),
path(
'logout/',
UserLogoutApi.as_view(),
name='logout'
),
path(
'me/',
UserMeApi.as_view(),
Expand Down