From 57171f6ef9fb301f6b70e22e056cd45142159548 Mon Sep 17 00:00:00 2001 From: Emmanuel Maunga Date: Tue, 29 Oct 2024 03:55:20 +0200 Subject: [PATCH 1/3] Installed gcc and libc6-dev because docker removes them after building Python, so its impossible to build C extensions afterwards, wcwidth==0.2.6 and cwcwidth==0.1.8 in this case --- backend/Dockerfile | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/backend/Dockerfile b/backend/Dockerfile index bc5f582..0100e49 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -2,6 +2,12 @@ FROM python:3.10-slim WORKDIR /app COPY requirements.txt /app +# Installing gcc and libc6-dev beacuse docker removes them after building Python, +# so it's impossible to build C extensions afterwards, wcwidth==0.2.6 and cwcwidth==0.1.8 in this case +RUN apt-get update && apt-get install -y \ + gcc \ + libc6-dev \ + && rm -rf /var/lib/apt/lists/* RUN pip install -r requirements.txt COPY . /app From 0f2c12d8ee94ca93c422b80d3ea87385d2a0ccfa Mon Sep 17 00:00:00 2001 From: Emmanuel Maunga Date: Tue, 29 Oct 2024 05:05:04 +0200 Subject: [PATCH 2/3] Added login and logout endpoints --- backend/Dockerfile | 2 +- backend/api/urls.py | 1 + backend/authentication/__init__.py | 0 backend/authentication/admin.py | 3 + backend/authentication/apps.py | 6 ++ backend/authentication/authenticate.py | 39 ++++++++++ backend/authentication/migrations/__init__.py | 0 backend/authentication/models.py | 3 + backend/authentication/serializers.py | 9 +++ backend/authentication/tests.py | 41 +++++++++++ backend/authentication/urls.py | 7 ++ backend/authentication/views.py | 68 ++++++++++++++++++ backend/chitchat/settings.py | 15 ++++ backend/requirements.txt | Bin 1772 -> 1834 bytes 14 files changed, 193 insertions(+), 1 deletion(-) create mode 100644 backend/authentication/__init__.py create mode 100644 backend/authentication/admin.py create mode 100644 backend/authentication/apps.py create mode 100644 backend/authentication/authenticate.py create mode 100644 backend/authentication/migrations/__init__.py create mode 100644 backend/authentication/models.py create mode 100644 backend/authentication/serializers.py create mode 100644 backend/authentication/tests.py create mode 100644 backend/authentication/urls.py create mode 100644 backend/authentication/views.py diff --git a/backend/Dockerfile b/backend/Dockerfile index 0100e49..cc11ffa 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -2,7 +2,7 @@ FROM python:3.10-slim WORKDIR /app COPY requirements.txt /app -# Installing gcc and libc6-dev beacuse docker removes them after building Python, +# Installing gcc and libc6-dev because docker removes them after building Python, # so it's impossible to build C extensions afterwards, wcwidth==0.2.6 and cwcwidth==0.1.8 in this case RUN apt-get update && apt-get install -y \ gcc \ diff --git a/backend/api/urls.py b/backend/api/urls.py index 157fdf2..82521fd 100644 --- a/backend/api/urls.py +++ b/backend/api/urls.py @@ -25,4 +25,5 @@ path("api/posts//", views.PostDetail.as_view(), name="post-detail"), path("api/posts/delete//", views.PostDelete.as_view(), name="post-delete"), path("api/signup", views.SignUpView.as_view(), name="signup-view"), + path("api/v2/auth/", include("authentication.urls")), ] diff --git a/backend/authentication/__init__.py b/backend/authentication/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/authentication/admin.py b/backend/authentication/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/backend/authentication/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/backend/authentication/apps.py b/backend/authentication/apps.py new file mode 100644 index 0000000..8bab8df --- /dev/null +++ b/backend/authentication/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class AuthenticationConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'authentication' diff --git a/backend/authentication/authenticate.py b/backend/authentication/authenticate.py new file mode 100644 index 0000000..02d4fac --- /dev/null +++ b/backend/authentication/authenticate.py @@ -0,0 +1,39 @@ +from rest_framework_simplejwt.authentication import JWTAuthentication +from django.conf import settings + +from rest_framework.authentication import CSRFCheck +from rest_framework import exceptions + + +def enforce_csrf(request): + """ + Enforce CSRF validation. + """ + + def dummy_get_response(request): # pragma: no cover + return None + + check = CSRFCheck(dummy_get_response) + # populates request.META['CSRF_COOKIE'], which is used in process_view() + check.process_request(request) + reason = check.process_view(request, None, (), {}) + if reason: + # CSRF failed, bail with explicit error message + raise exceptions.PermissionDenied('CSRF Failed: %s' % reason) + + +class CustomAuthentication(JWTAuthentication): + + def authenticate(self, request): + header = self.get_header(request) + + if header is None: + raw_token = request.COOKIES.get(settings.SIMPLE_JWT['AUTH_COOKIE']) or None + else: + raw_token = self.get_raw_token(header) + if raw_token is None: + return None + + validated_token = self.get_validated_token(raw_token) + # enforce_csrf(request) + return self.get_user(validated_token), validated_token diff --git a/backend/authentication/migrations/__init__.py b/backend/authentication/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/authentication/models.py b/backend/authentication/models.py new file mode 100644 index 0000000..71a8362 --- /dev/null +++ b/backend/authentication/models.py @@ -0,0 +1,3 @@ +from django.db import models + +# Create your models here. diff --git a/backend/authentication/serializers.py b/backend/authentication/serializers.py new file mode 100644 index 0000000..5c7a90c --- /dev/null +++ b/backend/authentication/serializers.py @@ -0,0 +1,9 @@ +from rest_framework import serializers + +from accounts.models import CustomUser + + +class AuthUserSerializer(serializers.ModelSerializer): + class Meta: + model = CustomUser + fields = ["username", "password"] diff --git a/backend/authentication/tests.py b/backend/authentication/tests.py new file mode 100644 index 0000000..9c5879e --- /dev/null +++ b/backend/authentication/tests.py @@ -0,0 +1,41 @@ +from django.test import TestCase +from django.urls import reverse +from rest_framework import status +from rest_framework.test import APIClient +from accounts.models import CustomUser +from django.conf import settings + +class LoginViewTestCase(TestCase): + def setUp(self): + LOGIN_URL = 'api/v2/auth/login/' + self.client = APIClient() + self.active_user = CustomUser.objects.create_user(username="activeuser", password="password123") + self.active_user.is_active = True + self.active_user.save() + + self.inactive_user = CustomUser.objects.create_user(username="inactiveuser", password="password123") + self.inactive_user.is_active = False + self.inactive_user.save() + + self.url = LOGIN_URL + + def test_login_successful(self): + response = self.client.post(self.url, {"username": "activeuser", "password": "password123"}) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertIn("access", response.data) + self.assertIn(settings.SIMPLE_JWT["AUTH_COOKIE"], response.cookies) + + def test_login_inactive_user(self): + response = self.client.post(self.url, {"username": "inactiveuser", "password": "password123"}) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual(response.data, {"details": "This account is not active."}) + + def test_login_invalid_credentials(self): + response = self.client.post(self.url, {"username": "wronguser", "password": "wrongpassword"}) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual(response.data, {"details": "Account with given credentials not found."}) + + def test_login_missing_fields(self): + response = self.client.post(self.url, {"username": "activeuser"}) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual(response.data, {"details": "Account with given credentials not found."}) diff --git a/backend/authentication/urls.py b/backend/authentication/urls.py new file mode 100644 index 0000000..10a9821 --- /dev/null +++ b/backend/authentication/urls.py @@ -0,0 +1,7 @@ +from django.urls import path +from . import views + +urlpatterns = [ + path("login/", views.LoginView.as_view(), name='login'), + path("logout/", views.LogoutView.as_view(), name='logout'), +] diff --git a/backend/authentication/views.py b/backend/authentication/views.py new file mode 100644 index 0000000..f23b2ed --- /dev/null +++ b/backend/authentication/views.py @@ -0,0 +1,68 @@ +from rest_framework_simplejwt.tokens import RefreshToken +from rest_framework.response import Response +from django.contrib.auth import authenticate +from rest_framework import status, generics +from django.conf import settings + +from authentication.serializers import AuthUserSerializer + + +def get_tokens_for_user(user): + refresh = RefreshToken.for_user(user) + + return { + "refresh": str(refresh), + "access": str(refresh.access_token), + } + + +class LoginView(generics.GenericAPIView): + permission_classes = [] + authentication_classes = [] + serializer_class = AuthUserSerializer + + def post(self, request, format=None): + data = request.data + response = Response() + username = data.get("username", None) + password = data.get("password", None) + user = authenticate(username=username, password=password) + + if user is not None: + if user.is_active: + data = get_tokens_for_user(user) + response.set_cookie( + key=settings.SIMPLE_JWT["AUTH_COOKIE"], + value=data["access"], + secure=settings.SIMPLE_JWT["AUTH_COOKIE_SECURE"], + httponly=settings.SIMPLE_JWT["AUTH_COOKIE_HTTP_ONLY"], + samesite=settings.SIMPLE_JWT["AUTH_COOKIE_SAMESITE"], + max_age=823396, + # domain='example.com' + ) + response.data = data + response.status_code = status.HTTP_200_OK + return response + else: + return Response({"details": "This account is not active."}, status=status.HTTP_400_BAD_REQUEST) + else: + return Response({"details": "Account with given credentials not found."}, status=status.HTTP_400_BAD_REQUEST) + + +class LogoutView(generics.GenericAPIView): + permission_classes = [] + authentication_classes = [] + serializer_class = None + + def post(self, request): + response = Response() + response.set_cookie( + key=settings.SIMPLE_JWT["AUTH_COOKIE"], + max_age=0, + # domain='example.com', + secure=settings.SIMPLE_JWT["AUTH_COOKIE_SECURE"], + expires="Thu, 01 Jan 1970 00:00:00 GMT", + samesite=settings.SIMPLE_JWT["AUTH_COOKIE_SAMESITE"] + ) + response.data = {"detail": "Logout successful."} + return response \ No newline at end of file diff --git a/backend/chitchat/settings.py b/backend/chitchat/settings.py index f51d466..eb8e272 100644 --- a/backend/chitchat/settings.py +++ b/backend/chitchat/settings.py @@ -11,6 +11,7 @@ """ from pathlib import Path +from datetime import timedelta # Build paths inside the project like this: BASE_DIR / 'subdir'. BASE_DIR = Path(__file__).resolve().parent.parent @@ -48,6 +49,7 @@ "api", "accounts", "core", + "authentication", # Dev tools "django_extensions", "drf_yasg", @@ -147,9 +149,22 @@ # "rest_framework.permissions.IsAuthenticated", ], "DEFAULT_FILTER_BACKENDS": ["django_filters.rest_framework.DjangoFilterBackend"], + "DEFAULT_AUTHENTICATION_CLASSES": ( + "authentication.authenticate.CustomAuthentication", + ), } CORS_ALLOWED_ORIGINS = [ "http://localhost:5173", ] + +SIMPLE_JWT = { + "AUTH_COOKIE": "access_token", # Cookie name. Enables cookies if value is set. + "AUTH_COOKIE_DOMAIN": None, # A string like "example.com", or None for standard domain cookie. + "AUTH_COOKIE_SECURE": True, # Whether the auth cookies should be secure (https:// only). + "AUTH_COOKIE_HTTP_ONLY": True, # Http only cookie flag.It's not fetch by javascript. + "AUTH_COOKIE_PATH": "/", # The path of the auth cookie. + "AUTH_COOKIE_SAMESITE": 'None', # Whether to set the flag restricting cookie leaks on cross-site requests. + "ACCESS_TOKEN_LIFETIME": timedelta(days=1) +} diff --git a/backend/requirements.txt b/backend/requirements.txt index b6e242457fb564bdc8c696a65f65f1b4345f439c..3887291f2540c45e1a0914b07bb51b5a7580dcca 100644 GIT binary patch delta 36 rcmaFEyNYka8@9<|OllIk48;tY47m&i3^@#`3|S223?&S_3|tHV$+iff delta 7 OcmZ3*_l9@F8#Vw9zXMbN From 1be0830cdd9303ce228d5a46166c351dc7142b73 Mon Sep 17 00:00:00 2001 From: Emmanuel Maunga Date: Fri, 8 Nov 2024 17:42:30 +0200 Subject: [PATCH 3/3] tried to make the linter happy --- backend/accounts/admin.py | 1 + backend/authentication/apps.py | 4 ++-- backend/authentication/authenticate.py | 9 +++----- backend/authentication/tests.py | 32 ++++++++++++++++++-------- backend/authentication/urls.py | 4 ++-- backend/authentication/views.py | 16 ++++++++----- backend/chitchat/settings.py | 4 ++-- backend/chitchat/urls.py | 1 + 8 files changed, 44 insertions(+), 27 deletions(-) diff --git a/backend/accounts/admin.py b/backend/accounts/admin.py index dc2695c..9d78759 100644 --- a/backend/accounts/admin.py +++ b/backend/accounts/admin.py @@ -1,6 +1,7 @@ """ Module for managing admin functionality related to accounts. """ + from django.contrib import admin from django.contrib.auth.admin import UserAdmin from django.contrib.auth.models import Group diff --git a/backend/authentication/apps.py b/backend/authentication/apps.py index 8bab8df..c65f1d2 100644 --- a/backend/authentication/apps.py +++ b/backend/authentication/apps.py @@ -2,5 +2,5 @@ class AuthenticationConfig(AppConfig): - default_auto_field = 'django.db.models.BigAutoField' - name = 'authentication' + default_auto_field = "django.db.models.BigAutoField" + name = "authentication" diff --git a/backend/authentication/authenticate.py b/backend/authentication/authenticate.py index 02d4fac..5f266c4 100644 --- a/backend/authentication/authenticate.py +++ b/backend/authentication/authenticate.py @@ -10,16 +10,14 @@ def enforce_csrf(request): Enforce CSRF validation. """ - def dummy_get_response(request): # pragma: no cover + def dummy_get_response(request): return None check = CSRFCheck(dummy_get_response) - # populates request.META['CSRF_COOKIE'], which is used in process_view() check.process_request(request) reason = check.process_view(request, None, (), {}) if reason: - # CSRF failed, bail with explicit error message - raise exceptions.PermissionDenied('CSRF Failed: %s' % reason) + raise exceptions.PermissionDenied("CSRF Failed: %s" % reason) class CustomAuthentication(JWTAuthentication): @@ -28,12 +26,11 @@ def authenticate(self, request): header = self.get_header(request) if header is None: - raw_token = request.COOKIES.get(settings.SIMPLE_JWT['AUTH_COOKIE']) or None + raw_token = request.COOKIES.get(settings.SIMPLE_JWT["AUTH_COOKIE"]) or None else: raw_token = self.get_raw_token(header) if raw_token is None: return None validated_token = self.get_validated_token(raw_token) - # enforce_csrf(request) return self.get_user(validated_token), validated_token diff --git a/backend/authentication/tests.py b/backend/authentication/tests.py index 9c5879e..9954393 100644 --- a/backend/authentication/tests.py +++ b/backend/authentication/tests.py @@ -1,41 +1,55 @@ from django.test import TestCase -from django.urls import reverse from rest_framework import status from rest_framework.test import APIClient from accounts.models import CustomUser from django.conf import settings + class LoginViewTestCase(TestCase): def setUp(self): - LOGIN_URL = 'api/v2/auth/login/' + LOGIN_URL = "api/v2/auth/login/" self.client = APIClient() - self.active_user = CustomUser.objects.create_user(username="activeuser", password="password123") + self.active_user = CustomUser.objects.create_user( + username="activeuser", password="password123" + ) self.active_user.is_active = True self.active_user.save() - self.inactive_user = CustomUser.objects.create_user(username="inactiveuser", password="password123") + self.inactive_user = CustomUser.objects.create_user( + username="inactiveuser", password="password123" + ) self.inactive_user.is_active = False self.inactive_user.save() self.url = LOGIN_URL def test_login_successful(self): - response = self.client.post(self.url, {"username": "activeuser", "password": "password123"}) + response = self.client.post( + self.url, {"username": "activeuser", "password": "password123"} + ) self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertIn("access", response.data) self.assertIn(settings.SIMPLE_JWT["AUTH_COOKIE"], response.cookies) def test_login_inactive_user(self): - response = self.client.post(self.url, {"username": "inactiveuser", "password": "password123"}) + response = self.client.post( + self.url, {"username": "inactiveuser", "password": "password123"} + ) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertEqual(response.data, {"details": "This account is not active."}) def test_login_invalid_credentials(self): - response = self.client.post(self.url, {"username": "wronguser", "password": "wrongpassword"}) + response = self.client.post( + self.url, {"username": "wronguser", "password": "wrongpassword"} + ) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertEqual(response.data, {"details": "Account with given credentials not found."}) + self.assertEqual( + response.data, {"details": "Account with given credentials not found."} + ) def test_login_missing_fields(self): response = self.client.post(self.url, {"username": "activeuser"}) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertEqual(response.data, {"details": "Account with given credentials not found."}) + self.assertEqual( + response.data, {"details": "Account with given credentials not found."} + ) diff --git a/backend/authentication/urls.py b/backend/authentication/urls.py index 10a9821..3dfe550 100644 --- a/backend/authentication/urls.py +++ b/backend/authentication/urls.py @@ -2,6 +2,6 @@ from . import views urlpatterns = [ - path("login/", views.LoginView.as_view(), name='login'), - path("logout/", views.LogoutView.as_view(), name='logout'), + path("login/", views.LoginView.as_view(), name="login"), + path("logout/", views.LogoutView.as_view(), name="logout"), ] diff --git a/backend/authentication/views.py b/backend/authentication/views.py index f23b2ed..3f361dd 100644 --- a/backend/authentication/views.py +++ b/backend/authentication/views.py @@ -38,15 +38,20 @@ def post(self, request, format=None): httponly=settings.SIMPLE_JWT["AUTH_COOKIE_HTTP_ONLY"], samesite=settings.SIMPLE_JWT["AUTH_COOKIE_SAMESITE"], max_age=823396, - # domain='example.com' ) response.data = data response.status_code = status.HTTP_200_OK return response else: - return Response({"details": "This account is not active."}, status=status.HTTP_400_BAD_REQUEST) + return Response( + {"details": "This account is not active."}, + status=status.HTTP_400_BAD_REQUEST, + ) else: - return Response({"details": "Account with given credentials not found."}, status=status.HTTP_400_BAD_REQUEST) + return Response( + {"details": "Account with given credentials not found."}, + status=status.HTTP_400_BAD_REQUEST, + ) class LogoutView(generics.GenericAPIView): @@ -59,10 +64,9 @@ def post(self, request): response.set_cookie( key=settings.SIMPLE_JWT["AUTH_COOKIE"], max_age=0, - # domain='example.com', secure=settings.SIMPLE_JWT["AUTH_COOKIE_SECURE"], expires="Thu, 01 Jan 1970 00:00:00 GMT", - samesite=settings.SIMPLE_JWT["AUTH_COOKIE_SAMESITE"] + samesite=settings.SIMPLE_JWT["AUTH_COOKIE_SAMESITE"], ) response.data = {"detail": "Logout successful."} - return response \ No newline at end of file + return response diff --git a/backend/chitchat/settings.py b/backend/chitchat/settings.py index eb8e272..0753df0 100644 --- a/backend/chitchat/settings.py +++ b/backend/chitchat/settings.py @@ -165,6 +165,6 @@ "AUTH_COOKIE_SECURE": True, # Whether the auth cookies should be secure (https:// only). "AUTH_COOKIE_HTTP_ONLY": True, # Http only cookie flag.It's not fetch by javascript. "AUTH_COOKIE_PATH": "/", # The path of the auth cookie. - "AUTH_COOKIE_SAMESITE": 'None', # Whether to set the flag restricting cookie leaks on cross-site requests. - "ACCESS_TOKEN_LIFETIME": timedelta(days=1) + "AUTH_COOKIE_SAMESITE": "None", # Whether to set the flag restricting cookie leaks on cross-site requests. + "ACCESS_TOKEN_LIFETIME": timedelta(days=1), } diff --git a/backend/chitchat/urls.py b/backend/chitchat/urls.py index 8f7ab97..c127a66 100644 --- a/backend/chitchat/urls.py +++ b/backend/chitchat/urls.py @@ -14,6 +14,7 @@ 1. Import the include() function: from django.urls import include, path 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) """ + from django.contrib import admin from django.urls import path, include from rest_framework import permissions