Skip to content

Commit 57f98d8

Browse files
authored
Merge pull request #73 from HackSoftware/jwt-based-authentication
JWT based authentication
2 parents 294e87a + 636407c commit 57f98d8

File tree

12 files changed

+383
-37
lines changed

12 files changed

+383
-37
lines changed

README.md

Lines changed: 52 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ The structure is inspired by [cookiecutter-django](https://github.com/pydanny/co
1616
Few important things:
1717

1818
* Linux / Ubuntu is our primary OS and things are tested for that. It will mostly not work on Mac & certainly not work on Windows.
19-
* It uses Postgres as primary database.
19+
* It uses Postgres as the primary database.
2020
* It comes with GitHub Actions support, [based on that article](https://hacksoft.io/github-actions-in-action-setting-up-django-and-postgres/)
2121
* It comes with examples for writing tests with fakes & factories, based on the following articles - <https://www.hacksoft.io/blog/improve-your-tests-django-fakes-and-factories>, <https://www.hacksoft.io/blog/improve-your-tests-django-fakes-and-factories-advanced-usage>
2222
* It comes with [`whitenoise`](http://whitenoise.evans.io/en/stable/) setup.
@@ -41,11 +41,46 @@ CORS_ALLOW_ALL_ORIGINS = False
4141
CORS_ORIGIN_WHITELIST = env.list('CORS_ORIGIN_WHITELIST', default=[])
4242
```
4343

44-
### DRF
44+
## Authentication - JWT
4545

46-
We have removed the default authentication classes, since they were causing trouble.
46+
The project is using <https://github.com/Styria-Digital/django-rest-framework-jwt> for having authentication via JWT capabilities.
4747

48-
## Authentication - General
48+
### Settings
49+
50+
All JWT related settings are located in `config/settings/jwt.py`.
51+
52+
> ⚠️ We highly recommend reading the entire settings page from the project documentation - <https://styria-digital.github.io/django-rest-framework-jwt/#additional-settings> - to figure out your needs & the proper defaults for you!
53+
54+
The default settings also include the JWT token as a cookie.
55+
56+
The specific details about how the cookie is set, can be found here - <https://github.com/Styria-Digital/django-rest-framework-jwt/blob/master/src/rest_framework_jwt/compat.py#L43>
57+
58+
### APIs
59+
60+
The JWT related APIs are:
61+
62+
1. `/api/auth/jwt/login/`
63+
1. `/api/auth/jwt/logout/`
64+
65+
The current implementation of the login API returns just the token:
66+
67+
```json
68+
{
69+
"token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VybmFtZSI6InJhZG9yYWRvQGhhY2tzb2Z0LmlvIiwiaWF0IjoxNjQxMjIxMDMxLCJleHAiOjE2NDE4MjU4MzEsImp0aSI6ImIyNTEyNmY4LTM3ZDctNGI5NS04Y2M0LTkzZjI3MjE4ZGZkOSIsInVzZXJfaWQiOjJ9.TUoQQPSijO2O_3LN-Pny4wpQp-0rl4lpTs_ulkbxzO4"
70+
}
71+
```
72+
73+
This can be changed from `auth_jwt_response_payload_handler`.
74+
75+
76+
### Requiring authentication
77+
78+
We follow this concept:
79+
80+
1. All APIs are public by default (no default authentication classes)
81+
1. If you want a certain API to require authentication, you add the `ApiAuthMixin` to it.
82+
83+
## Authentication - Sessions
4984

5085
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:
5186

@@ -104,22 +139,15 @@ We have the following general cases:
104139
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.
105140
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`
106141

107-
### Reading list
108-
109-
Since cookies can be somewhat elusive, check the following urls:
110-
111-
1. <https://docs.djangoproject.com/en/3.1/ref/settings/#sessions> - It's a good idea to just read every description for `SESSION_*`
112-
1. <https://developer.mozilla.org/en-US/docs/Web/HTTP/Cookies> - It's a good idea to read everything, several times.
113-
114-
## Authentication APIs
142+
### APIs
115143

116-
1. `POST` <http://localhost:8000/api/auth/login/> requires JSON body with `email` and `password`.
117-
1. `GET` <http://localhost:8000/api/auth/me/> returns the current user information, if the request is authenticated (has the corresponding `sessionid` cookie)
118-
1. `GET` or `POST` <http://localhost:8000/api/auth/logout/> will remove the `sessionid` cookie, effectively logging you out.
144+
1. `POST` to `/api/auth/session/login/` requires JSON body with `email` and `password`.
145+
1. `GET` to `/api/auth/me/` returns the current user information, if the request is authenticated (has the corresponding `sessionid` cookie)
146+
1. `GET` or `POST` to `/api/auth/logout/` will remove the `sessionid` cookie, effectively logging you out.
119147

120148
### `HTTP Only` / `SameSite`
121149

122-
The current implementation of `/auth/login` does 2 things:
150+
The current implementation of `/api/auth/session/login` does 2 things:
123151

124152
1. Sets a `HTTP Only` cookie with the session id.
125153
1. Returns the actual session id from the JSON payload.
@@ -128,6 +156,14 @@ The second thing is required, because Safari is not respecting the `SameSite = N
128156

129157
More on the issue here - <https://www.chromium.org/updates/same-site/incompatible-clients>
130158

159+
### Reading list
160+
161+
Since cookies can be somewhat elusive, check the following urls:
162+
163+
1. <https://docs.djangoproject.com/en/3.1/ref/settings/#sessions> - It's a good idea to just read every description for `SESSION_*`
164+
1. <https://developer.mozilla.org/en-US/docs/Web/HTTP/Cookies> - It's a good idea to read everything, several times.
165+
166+
131167
## Example List API
132168

133169
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/django/base.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@
5050
'django_filters',
5151
'corsheaders',
5252
'django_extensions',
53+
'rest_framework_jwt',
5354
]
5455

5556
INSTALLED_APPS = [
@@ -169,6 +170,7 @@
169170
}
170171

171172
from config.settings.cors import * # noqa
173+
from config.settings.jwt import * # noqa
172174
from config.settings.sessions import * # noqa
173175
from config.settings.celery import * # noqa
174176
from config.settings.sentry import * # noqa

config/settings/jwt.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import datetime
2+
3+
from config.env import env
4+
5+
# For more settings
6+
# Read everything from here - https://styria-digital.github.io/django-rest-framework-jwt/#additional-settings
7+
8+
# Default to 7 days
9+
JWT_EXPIRATION_DELTA_SECONDS = env("JWT_EXPIRATION_DELTA_SECONDS", default=60 * 60 * 24 * 7)
10+
JWT_AUTH_COOKIE = env("JWT_AUTH_COOKIE", default="jwt")
11+
JWT_AUTH_COOKIE_SAMESITE = env("JWT_AUTH_COOKIE_SAMESITE", default="Lax")
12+
JWT_AUTH_HEADER_PREFIX = env("JWT_AUTH_HEADER_PREFIX", default="Bearer")
13+
14+
15+
JWT_AUTH = {
16+
"JWT_GET_USER_SECRET_KEY": "styleguide_example.authentication.services.auth_user_get_jwt_secret_key",
17+
"JWT_RESPONSE_PAYLOAD_HANDLER": "styleguide_example.authentication.services.auth_jwt_response_payload_handler",
18+
"JWT_EXPIRATION_DELTA": datetime.timedelta(seconds=JWT_EXPIRATION_DELTA_SECONDS),
19+
"JWT_ALLOW_REFRESH": False,
20+
21+
"JWT_AUTH_COOKIE": JWT_AUTH_COOKIE,
22+
"JWT_AUTH_COOKIE_SECURE": True,
23+
"JWT_AUTH_COOKIE_SAMESITE": JWT_AUTH_COOKIE_SAMESITE,
24+
25+
"JWT_AUTH_HEADER_PREFIX": JWT_AUTH_HEADER_PREFIX
26+
}

requirements/base.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,5 @@ whitenoise==5.3.0
1212
django-filter==21.1
1313
django-cors-headers==3.10.1
1414
django-extensions==3.1.5
15+
16+
drf-jwt==1.19.1

styleguide_example/api/mixins.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77
from rest_framework.permissions import IsAuthenticated
88
from rest_framework.authentication import SessionAuthentication, BaseAuthentication
99

10+
from rest_framework_jwt.authentication import JSONWebTokenAuthentication
11+
1012

1113
def get_auth_header(headers):
1214
value = headers.get('Authorization')
@@ -59,5 +61,9 @@ def enforce_csrf(self, request):
5961

6062

6163
class ApiAuthMixin:
62-
authentication_classes = (CsrfExemptedSessionAuthentication, SessionAsHeaderAuthentication)
64+
authentication_classes = (
65+
CsrfExemptedSessionAuthentication,
66+
SessionAsHeaderAuthentication,
67+
JSONWebTokenAuthentication
68+
)
6369
permission_classes = (IsAuthenticated, )

styleguide_example/authentication/apis.py

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,21 @@
11
from django.contrib.auth import authenticate, login, logout
2+
from django.conf import settings
23

34
from rest_framework.views import APIView
45
from rest_framework.response import Response
56
from rest_framework import serializers
67
from rest_framework import status
78

9+
from rest_framework_jwt.views import ObtainJSONWebTokenView
10+
811
from styleguide_example.api.mixins import ApiAuthMixin
912

13+
from styleguide_example.authentication.services import auth_logout
14+
1015
from styleguide_example.users.selectors import user_get_login_data
1116

1217

13-
class UserLoginApi(APIView):
18+
class UserSessionLoginApi(APIView):
1419
"""
1520
Following https://docs.djangoproject.com/en/3.1/topics/auth/default/#how-to-log-a-user-in
1621
"""
@@ -22,12 +27,10 @@ def post(self, request):
2227
serializer = self.InputSerializer(data=request.data)
2328
serializer.is_valid(raise_exception=True)
2429

25-
print(request.user)
2630
user = authenticate(request, **serializer.validated_data)
27-
print(user)
2831

2932
if user is None:
30-
return Response(status=status.HTTP_401_UNAUTHORIZED)
33+
return Response(status=status.HTTP_400_BAD_REQUEST)
3134

3235
login(request, user)
3336

@@ -40,7 +43,7 @@ def post(self, request):
4043
})
4144

4245

43-
class UserLogoutApi(APIView):
46+
class UserSessionLogoutApi(APIView):
4447
def get(self, request):
4548
logout(request)
4649

@@ -52,6 +55,30 @@ def post(self, request):
5255
return Response()
5356

5457

58+
class UserJwtLoginApi(ObtainJSONWebTokenView):
59+
def post(self, request, *args, **kwargs):
60+
# We are redefining post so we can change the response status on success
61+
# Mostly for consistency with the session-based API
62+
response = super().post(request, *args, **kwargs)
63+
64+
if response.status_code == status.HTTP_201_CREATED:
65+
response.status_code = status.HTTP_200_OK
66+
67+
return response
68+
69+
70+
class UserJwtLogoutApi(ApiAuthMixin, APIView):
71+
def post(self, request):
72+
auth_logout(request.user)
73+
74+
response = Response()
75+
76+
if settings.JWT_AUTH['JWT_AUTH_COOKIE'] is not None:
77+
response.delete_cookie(settings.JWT_AUTH['JWT_AUTH_COOKIE'])
78+
79+
return response
80+
81+
5582
class UserMeApi(ApiAuthMixin, APIView):
5683
def get(self, request):
5784
data = user_get_login_data(user=request.user)
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import uuid
2+
3+
from styleguide_example.users.models import BaseUser
4+
5+
6+
def auth_user_get_jwt_secret_key(user: BaseUser) -> str:
7+
return str(user.jwt_key)
8+
9+
10+
def auth_jwt_response_payload_handler(token, user=None, request=None, issued_at=None):
11+
"""
12+
Default implementation. Add whatever suits you here.
13+
"""
14+
return {"token": token}
15+
16+
17+
def auth_logout(user: BaseUser) -> BaseUser:
18+
user.jwt_key = uuid.uuid4()
19+
user.full_clean()
20+
user.save(update_fields=["jwt_key"])
21+
22+
return user

0 commit comments

Comments
 (0)