Skip to content

Commit 0310d8d

Browse files
committed
Introduce cookie-based session authentication
1 parent bfeee2e commit 0310d8d

File tree

9 files changed

+180
-9
lines changed

9 files changed

+180
-9
lines changed

README.md

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,100 @@ Few important things:
1313
* It can be easily deployed to Heroku.
1414
* It comes with an example list API, that uses [`django-filter`](https://django-filter.readthedocs.io/en/stable/) for filtering & pagination from DRF.
1515

16+
## General API Stuff
17+
18+
### CORS
19+
20+
The project is running [`django-cors-headers`](https://github.com/adamchainz/django-cors-headers) with the following general configuration:
21+
22+
```python
23+
CORS_ALLOW_CREDENTIALS = True
24+
CORS_ALLOW_ALL_ORIGINS = True
25+
```
26+
27+
For `production.py`, we have the following:
28+
29+
```python
30+
CORS_ALLOW_ALL_ORIGINS = False
31+
CORS_ORIGIN_WHITELIST = env.list('DJANGO_CORS_ORIGIN_WHITELIST', default=[])
32+
```
33+
34+
### DRF
35+
36+
We have removed the default authentication classes, since they were causing trouble.
37+
38+
## Authentication - General
39+
40+
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:
41+
42+
1. On successful authentication, Django returns the `sessionid` cookie:
43+
44+
```
45+
sessionid=5yic8rov868prmfoin2vhtg4vx35h71p; expires=Tue, 13 Apr 2021 11:17:58 GMT; HttpOnly; Max-Age=1209600; Path=/; SameSite=Lax
46+
```
47+
48+
2. When making calls from the frontend, don't forget to **include credentials**. For example, when using `axios`:
49+
50+
```javascript
51+
axios.get(url, { withCredentials: true });
52+
axios.post(url, data, { withCredentials: true });
53+
```
54+
55+
3. For convenience, `CSRF_USE_SESSIONS` is set to `True`
56+
57+
4. Check `config/settings/sessions.py` for all configuration that's related to sessions.
58+
59+
### DRF & Overriding `SessionAuthentication`
60+
61+
Since the default implementation of `SessionAuthentication` enforces CSRF check, which is not the desired behavior for our APIs, we've done the following:
62+
63+
```python
64+
from rest_framework.authentication import SessionAuthentication
65+
66+
67+
class CsrfExemptedSessionAuthentication(SessionAuthentication):
68+
"""
69+
DRF SessionAuthentication is enforcing CSRF, which may be problematic.
70+
That's why we want to make sure we are exempting any kind of CSRF checks for APIs.
71+
"""
72+
def enforce_csrf(self, request):
73+
return
74+
```
75+
76+
Which is then used to construct an `ApiAuthMixin`, which marks an API that requires authentication:
77+
78+
```python
79+
from rest_framework.permissions import IsAuthenticated
80+
81+
82+
class ApiAuthMixin:
83+
authentication_classes = (CsrfExemptedSessionAuthentication, )
84+
permission_classes = (IsAuthenticated, )
85+
```
86+
87+
**By default, all APIs are public, unless you add the `ApiAuthMixin`**
88+
89+
### Cross origin
90+
91+
We have the following general cases:
92+
93+
1. The current configuration works out of the box for `localhost` development.
94+
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.
95+
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`
96+
97+
### Reading list
98+
99+
Since cookies can be somewhat elusive, check the following urls:
100+
101+
1. <https://docs.djangoproject.com/en/3.1/ref/settings/#sessions> - It's a good idea to just read every description for `SESSION_*`
102+
1. <https://developer.mozilla.org/en-US/docs/Web/HTTP/Cookies> - It's a good idea to read everything, several times.
103+
104+
## Authentication APIs
105+
106+
1. `POST` <http://localhost:8000/api/auth/login/> requires JSON body with `email` and `password`.
107+
1. `GET` <http://localhost:8000/api/auth/me/> returns the current user information, if the request is authenticated (has the corresponding `sessionid` cookie)
108+
1. `GET` or `POST` <http://localhost:8000/api/auth/logout/> will remove the `sessionid` cookie, effectively logging you out.
109+
16110
## Example List API
17111

18112
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)

config/settings/base.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@
2727
# SECURITY WARNING: don't run with debug turned on in production!
2828
DEBUG = True
2929

30-
ALLOWED_HOSTS = []
30+
ALLOWED_HOSTS = ['*']
3131

3232

3333
# Application definition
@@ -42,7 +42,8 @@
4242
'rest_framework',
4343
'django_celery_results',
4444
'django_celery_beat',
45-
'django_filters'
45+
'django_filters',
46+
'corsheaders'
4647
]
4748

4849
INSTALLED_APPS = [
@@ -58,6 +59,7 @@
5859

5960
MIDDLEWARE = [
6061
'django.middleware.security.SecurityMiddleware',
62+
'corsheaders.middleware.CorsMiddleware',
6163
'whitenoise.middleware.WhiteNoiseMiddleware',
6264
'django.contrib.sessions.middleware.SessionMiddleware',
6365
'django.middleware.common.CommonMiddleware',
@@ -156,6 +158,9 @@
156158
'DEFAULT_FILTER_BACKENDS': (
157159
'django_filters.rest_framework.DjangoFilterBackend',
158160
),
161+
'DEFAULT_AUTHENTICATION_CLASSES': []
159162
}
160163

164+
from .cors import * # noqa
165+
from .sessions import * # noqa
161166
from .celery import * # noqa

config/settings/cors.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
CORS_ALLOW_CREDENTIALS = True
2+
CORS_ALLOW_ALL_ORIGINS = True

config/settings/production.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,15 @@
77

88
ALLOWED_HOSTS = env.list('DJANGO_ALLOWED_HOSTS', default=[])
99

10+
CORS_ALLOW_ALL_ORIGINS = False
11+
CORS_ORIGIN_WHITELIST = env.list('DJANGO_CORS_ORIGIN_WHITELIST', default=[])
12+
13+
SESSION_COOKIE_SECURE = env.bool('DJANGO_SESSION_COOKIE_SECURE', default=True)
14+
1015
# https://docs.djangoproject.com/en/dev/ref/settings/#secure-proxy-ssl-header
1116
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
1217
# https://docs.djangoproject.com/en/dev/ref/settings/#secure-ssl-redirect
1318
SECURE_SSL_REDIRECT = env.bool("DJANGO_SECURE_SSL_REDIRECT", default=True)
14-
# https://docs.djangoproject.com/en/dev/ref/settings/#session-cookie-secure
15-
SESSION_COOKIE_SECURE = True
16-
# https://docs.djangoproject.com/en/dev/ref/settings/#csrf-cookie-secure
17-
CSRF_COOKIE_SECURE = True
1819
# https://docs.djangoproject.com/en/dev/ref/middleware/#x-content-type-options-nosniff
1920
SECURE_CONTENT_TYPE_NOSNIFF = env.bool(
2021
"DJANGO_SECURE_CONTENT_TYPE_NOSNIFF", default=True

config/settings/sessions.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
from .env_reader import env
2+
3+
4+
"""
5+
Do read:
6+
7+
1. https://docs.djangoproject.com/en/3.1/ref/settings/#sessions
8+
2. https://developer.mozilla.org/en-US/docs/Web/HTTP/Cookies
9+
"""
10+
SESSION_COOKIE_AGE = env.int('DJANGO_SESSION_COOKIE_AGE', default=1209600) # Default - 2 weeks in seconds
11+
SESSION_COOKIE_HTTPONLY = env.bool('DJANGO_SESSION_COOKIE_HTTPONLY', default=True)
12+
SESSION_COOKIE_NAME = env('DJANGO_SESSION_COOKIE_NAME', default='sessionid')
13+
SESSION_COOKIE_SAMESITE = env('DJANGO_SESSION_COOKE_SAMESITE', default='Lax')
14+
SESSION_COOKIE_SECURE = env.bool('DJANGO_SESSION_COOKIE_SECURE', default=False)
15+
16+
CSRF_USE_SESSIONS = env.bool('DJANGO_CSRF_USE_SESSIONS', default=True)

requirements/base.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,4 @@ django-celery-beat==2.0.0
1010
whitenoise==5.1.0
1111

1212
django-filter==2.4.0
13+
django-cors-headers==3.7.0

styleguide_example/api/mixins.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,17 @@
77
from styleguide_example.api.errors import get_error_message
88

99

10+
class CsrfExemptedSessionAuthentication(SessionAuthentication):
11+
"""
12+
DRF SessionAuthentication is enforcing CSRF, which may be problematic.
13+
That's why we want to make sure we are exempting any kind of CSRF checks for APIs.
14+
"""
15+
def enforce_csrf(self, request):
16+
return
17+
18+
1019
class ApiAuthMixin:
11-
authentication_classes = (SessionAuthentication, )
20+
authentication_classes = (CsrfExemptedSessionAuthentication, )
1221
permission_classes = (IsAuthenticated, )
1322

1423

styleguide_example/authentication/apis.py

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,51 @@
1+
from django.contrib.auth import authenticate, login, logout
2+
13
from rest_framework.views import APIView
24
from rest_framework.response import Response
5+
from rest_framework import serializers
6+
from rest_framework import status
37

48
from styleguide_example.api.mixins import ApiAuthMixin
59

610
from styleguide_example.users.selectors import user_get_login_data
711

812

913
class UserLoginApi(APIView):
10-
pass
14+
"""
15+
Following https://docs.djangoproject.com/en/3.1/topics/auth/default/#how-to-log-a-user-in
16+
"""
17+
class InputSerializer(serializers.Serializer):
18+
email = serializers.EmailField()
19+
password = serializers.CharField()
20+
21+
def post(self, request):
22+
serializer = self.InputSerializer(data=request.data)
23+
serializer.is_valid(raise_exception=True)
24+
25+
print(request.user)
26+
user = authenticate(request, **serializer.validated_data)
27+
print(user)
28+
29+
if user is None:
30+
return Response(status=status.HTTP_401_UNAUTHORIZED)
31+
32+
login(request, user)
33+
34+
data = user_get_login_data(user=user)
35+
36+
return Response(data)
37+
38+
39+
class UserLogoutApi(APIView):
40+
def get(self, request):
41+
logout(request)
42+
43+
return Response()
44+
45+
def post(self, request):
46+
logout(request)
47+
48+
return Response()
1149

1250

1351
class UserMeApi(ApiAuthMixin, APIView):

styleguide_example/authentication/urls.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,18 @@
11
from django.urls import path
22

3-
from .apis import UserLoginApi, UserMeApi
3+
from .apis import UserLoginApi, UserLogoutApi, UserMeApi
44

55
urlpatterns = [
66
path(
77
'login/',
88
UserLoginApi.as_view(),
99
name='login'
1010
),
11+
path(
12+
'logout/',
13+
UserLogoutApi.as_view(),
14+
name='logout'
15+
),
1116
path(
1217
'me/',
1318
UserMeApi.as_view(),

0 commit comments

Comments
 (0)