Skip to content

Commit 75cc776

Browse files
committed
Introduce JWT based login/logout
- APIs & Tests
1 parent d20172d commit 75cc776

File tree

9 files changed

+238
-5
lines changed

9 files changed

+238
-5
lines changed

config/django/base.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,7 @@
170170
}
171171

172172
from config.settings.cors import * # noqa
173+
from config.settings.jwt import * # noqa
173174
from config.settings.sessions import * # noqa
174175
from config.settings.celery import * # noqa
175176
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+
}

styleguide_example/authentication/apis.py

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,17 @@
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

@@ -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

@@ -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

styleguide_example/authentication/tests/apis/test_user_login.py

Lines changed: 99 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from django.test import TestCase
22
from django.urls import reverse
3+
from django.conf import settings
34

45
from rest_framework.test import APIClient
56

@@ -25,7 +26,7 @@ def test_non_existing_user_cannot_login(self):
2526

2627
response = self.client.post(self.session_login_url, data)
2728

28-
self.assertEqual(401, response.status_code)
29+
self.assertEqual(400, response.status_code)
2930

3031
def test_existing_user_can_login_and_access_apis(self):
3132
"""
@@ -94,3 +95,100 @@ def test_existing_user_can_logout(self):
9495

9596
response = self.client.get(self.me_url)
9697
self.assertEqual(403, response.status_code)
98+
99+
100+
class UserJwtLoginTests(TestCase):
101+
def setUp(self):
102+
self.client = APIClient()
103+
104+
self.jwt_login_url = reverse('api:authentication:jwt:login')
105+
self.jwt_logout_url = reverse('api:authentication:jwt:logout')
106+
self.me_url = reverse('api:authentication:me')
107+
108+
def test_non_existing_user_cannot_login(self):
109+
self.assertEqual(0, BaseUser.objects.count())
110+
111+
data = {
112+
'email': '[email protected]',
113+
'password': 'hacksoft'
114+
}
115+
116+
response = self.client.post(self.jwt_login_url, data)
117+
118+
self.assertEqual(400, response.status_code)
119+
120+
def test_existing_user_can_login_and_access_apis(self):
121+
"""
122+
1. Create user
123+
2. Assert login is OK
124+
3. Call /api/auth/me
125+
4. Assert valid response
126+
"""
127+
credentials = {
128+
"email": "[email protected]",
129+
"password": "password"
130+
}
131+
132+
user_create(
133+
**credentials
134+
)
135+
136+
response = self.client.post(self.jwt_login_url, credentials)
137+
138+
self.assertEqual(200, response.status_code)
139+
140+
data = response.data
141+
self.assertIn("token", data)
142+
token = data["token"]
143+
144+
jwt_cookie = response.cookies.get(settings.JWT_AUTH["JWT_AUTH_COOKIE"])
145+
146+
self.assertEqual(token, jwt_cookie.value)
147+
148+
response = self.client.get(self.me_url)
149+
self.assertEqual(200, response.status_code)
150+
151+
# Now, try without session attached to the client
152+
client = APIClient()
153+
154+
response = client.get(self.me_url)
155+
self.assertEqual(403, response.status_code)
156+
157+
auth_headers = {
158+
"HTTP_AUTHORIZATION": f"{settings.JWT_AUTH['JWT_AUTH_HEADER_PREFIX']} {token}"
159+
}
160+
response = client.get(self.me_url, **auth_headers)
161+
self.assertEqual(200, response.status_code)
162+
163+
def test_existing_user_can_logout(self):
164+
"""
165+
1. Create user
166+
2. Login, can access APIs
167+
3. Logout, cannot access APIs
168+
"""
169+
credentials = {
170+
"email": "[email protected]",
171+
"password": "password"
172+
}
173+
174+
user = user_create(
175+
**credentials
176+
)
177+
178+
key_before_logout = user.jwt_key
179+
180+
response = self.client.post(self.jwt_login_url, credentials)
181+
self.assertEqual(200, response.status_code)
182+
183+
response = self.client.get(self.me_url)
184+
self.assertEqual(200, response.status_code)
185+
186+
self.client.post(self.jwt_logout_url)
187+
188+
response = self.client.get(self.me_url)
189+
self.assertEqual(403, response.status_code)
190+
191+
user.refresh_from_db()
192+
key_after_logout = user.jwt_key
193+
194+
self.assertNotEqual(key_before_logout, key_after_logout)

styleguide_example/authentication/urls.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,10 @@
33
from .apis import (
44
UserSessionLoginApi,
55
UserSessionLogoutApi,
6+
7+
UserJwtLoginApi,
8+
UserJwtLogoutApi,
9+
610
UserMeApi,
711
)
812

@@ -23,6 +27,21 @@
2327

2428
], "session"))
2529
),
30+
path(
31+
'jwt/',
32+
include(([
33+
path(
34+
"login/",
35+
UserJwtLoginApi.as_view(),
36+
name="login"
37+
),
38+
path(
39+
"logout/",
40+
UserJwtLogoutApi.as_view(),
41+
name="logout"
42+
)
43+
], "jwt"))
44+
),
2645
path(
2746
'me/',
2847
UserMeApi.as_view(),

styleguide_example/users/admin.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,25 @@ class BaseUserAdmin(admin.ModelAdmin):
1414
list_filter = ('is_active', 'is_admin', 'is_superuser')
1515

1616
fieldsets = (
17-
(None, {'fields': ('email',)}),
17+
(
18+
None, {
19+
'fields': ('email',)
20+
}
21+
),
22+
(
23+
"Booleans", {
24+
"fields": ("is_active", "is_admin", "is_superuser")
25+
}
26+
),
27+
(
28+
"Timestamps", {
29+
"fields": ("created_at", "updated_at")
30+
}
31+
)
1832
)
1933

34+
readonly_fields = ("created_at", "updated_at", )
35+
2036
def save_model(self, request, obj, form, change):
2137
if change:
2238
return super().save_model(request, obj, form, change)
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
# Generated by Django 3.2.10 on 2021-12-20 14:24
2+
3+
from django.db import migrations, models
4+
import uuid
5+
6+
7+
class Migration(migrations.Migration):
8+
9+
dependencies = [
10+
('users', '0002_alter_baseuser_id'),
11+
]
12+
13+
operations = [
14+
migrations.AddField(
15+
model_name='baseuser',
16+
name='jwt_key',
17+
field=models.UUIDField(default=uuid.uuid4),
18+
),
19+
]

styleguide_example/users/models.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import uuid
2+
13
from django.db import models
24
from django.contrib.auth.models import (
35
BaseUserManager as BUM,
@@ -58,6 +60,9 @@ class BaseUser(BaseModel, AbstractBaseUser, PermissionsMixin):
5860
is_active = models.BooleanField(default=True)
5961
is_admin = models.BooleanField(default=False)
6062

63+
# This should potentially be an encrypted field
64+
jwt_key = models.UUIDField(default=uuid.uuid4)
65+
6166
objects = BaseUserManager()
6267

6368
USERNAME_FIELD = 'email'

0 commit comments

Comments
 (0)