Skip to content

Commit d42c94d

Browse files
authored
Merge pull request #72 from PythonBulawayo/auth
Added authentication endpoints
2 parents 16ffbb1 + d984afc commit d42c94d

File tree

14 files changed

+209
-0
lines changed

14 files changed

+209
-0
lines changed

backend/Dockerfile

+6
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,12 @@ FROM python:3.10-slim
22

33
WORKDIR /app
44
COPY requirements.txt /app
5+
# Installing gcc and libc6-dev because docker removes them after building Python,
6+
# so it's impossible to build C extensions afterwards, wcwidth==0.2.6 and cwcwidth==0.1.8 in this case
7+
RUN apt-get update && apt-get install -y \
8+
gcc \
9+
libc6-dev \
10+
&& rm -rf /var/lib/apt/lists/*
511
RUN pip install -r requirements.txt
612
COPY . /app
713

backend/accounts/admin.py

+1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""
22
Module for managing admin functionality related to accounts.
33
"""
4+
45
from django.contrib import admin
56
from django.contrib.auth.admin import UserAdmin
67
from django.contrib.auth.models import Group

backend/api/urls.py

+1
Original file line numberDiff line numberDiff line change
@@ -25,4 +25,5 @@
2525
path("api/posts/<int:pk>/", views.PostDetail.as_view(), name="post-detail"),
2626
path("api/posts/delete/<int:pk>/", views.PostDelete.as_view(), name="post-delete"),
2727
path("api/signup", views.SignUpView.as_view(), name="signup-view"),
28+
path("api/auth/", include("authentication.urls")),
2829
]

backend/authentication/__init__.py

Whitespace-only changes.

backend/authentication/apps.py

+6
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
from django.apps import AppConfig
2+
3+
4+
class AuthenticationConfig(AppConfig):
5+
default_auto_field = "django.db.models.BigAutoField"
6+
name = "authentication"
+36
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
from rest_framework_simplejwt.authentication import JWTAuthentication
2+
from django.conf import settings
3+
4+
from rest_framework.authentication import CSRFCheck
5+
from rest_framework import exceptions
6+
7+
8+
def enforce_csrf(request):
9+
"""
10+
Enforce CSRF validation.
11+
"""
12+
13+
def dummy_get_response(request):
14+
return None
15+
16+
check = CSRFCheck(dummy_get_response)
17+
check.process_request(request)
18+
reason = check.process_view(request, None, (), {})
19+
if reason:
20+
raise exceptions.PermissionDenied("CSRF Failed: %s" % reason)
21+
22+
23+
class CustomAuthentication(JWTAuthentication):
24+
25+
def authenticate(self, request):
26+
header = self.get_header(request)
27+
28+
if header is None:
29+
raw_token = request.COOKIES.get(settings.SIMPLE_JWT["AUTH_COOKIE"]) or None
30+
else:
31+
raw_token = self.get_raw_token(header)
32+
if raw_token is None:
33+
return None
34+
35+
validated_token = self.get_validated_token(raw_token)
36+
return self.get_user(validated_token), validated_token

backend/authentication/migrations/__init__.py

Whitespace-only changes.

backend/authentication/serializers.py

+9
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
from rest_framework import serializers
2+
3+
from accounts.models import CustomUser
4+
5+
6+
class AuthUserSerializer(serializers.ModelSerializer):
7+
class Meta:
8+
model = CustomUser
9+
fields = ["username", "password"]

backend/authentication/tests.py

+55
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
from django.test import TestCase
2+
from rest_framework import status
3+
from rest_framework.test import APIClient
4+
from accounts.models import CustomUser
5+
from django.conf import settings
6+
7+
8+
class LoginViewTestCase(TestCase):
9+
def setUp(self):
10+
LOGIN_URL = "api/v2/auth/login/"
11+
self.client = APIClient()
12+
self.active_user = CustomUser.objects.create_user(
13+
username="activeuser", password="password123"
14+
)
15+
self.active_user.is_active = True
16+
self.active_user.save()
17+
18+
self.inactive_user = CustomUser.objects.create_user(
19+
username="inactiveuser", password="password123"
20+
)
21+
self.inactive_user.is_active = False
22+
self.inactive_user.save()
23+
24+
self.url = LOGIN_URL
25+
26+
def test_login_successful(self):
27+
response = self.client.post(
28+
self.url, {"username": "activeuser", "password": "password123"}
29+
)
30+
self.assertEqual(response.status_code, status.HTTP_200_OK)
31+
self.assertIn("access", response.data)
32+
self.assertIn(settings.SIMPLE_JWT["AUTH_COOKIE"], response.cookies)
33+
34+
def test_login_inactive_user(self):
35+
response = self.client.post(
36+
self.url, {"username": "inactiveuser", "password": "password123"}
37+
)
38+
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
39+
self.assertEqual(response.data, {"details": "This account is not active."})
40+
41+
def test_login_invalid_credentials(self):
42+
response = self.client.post(
43+
self.url, {"username": "wronguser", "password": "wrongpassword"}
44+
)
45+
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
46+
self.assertEqual(
47+
response.data, {"details": "Account with given credentials not found."}
48+
)
49+
50+
def test_login_missing_fields(self):
51+
response = self.client.post(self.url, {"username": "activeuser"})
52+
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
53+
self.assertEqual(
54+
response.data, {"details": "Account with given credentials not found."}
55+
)

backend/authentication/urls.py

+7
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
from django.urls import path
2+
from . import views
3+
4+
urlpatterns = [
5+
path("login/", views.LoginView.as_view(), name="login"),
6+
path("logout/", views.LogoutView.as_view(), name="logout"),
7+
]

backend/authentication/views.py

+72
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
from rest_framework_simplejwt.tokens import RefreshToken
2+
from rest_framework.response import Response
3+
from django.contrib.auth import authenticate
4+
from rest_framework import status, generics
5+
from django.conf import settings
6+
7+
from authentication.serializers import AuthUserSerializer
8+
9+
10+
def get_tokens_for_user(user):
11+
refresh = RefreshToken.for_user(user)
12+
13+
return {
14+
"refresh": str(refresh),
15+
"access": str(refresh.access_token),
16+
}
17+
18+
19+
class LoginView(generics.GenericAPIView):
20+
permission_classes = []
21+
authentication_classes = []
22+
serializer_class = AuthUserSerializer
23+
24+
def post(self, request, format=None):
25+
data = request.data
26+
response = Response()
27+
username = data.get("username", None)
28+
password = data.get("password", None)
29+
user = authenticate(username=username, password=password)
30+
31+
if user is not None:
32+
if user.is_active:
33+
data = get_tokens_for_user(user)
34+
response.set_cookie(
35+
key=settings.SIMPLE_JWT["AUTH_COOKIE"],
36+
value=data["access"],
37+
secure=settings.SIMPLE_JWT["AUTH_COOKIE_SECURE"],
38+
httponly=settings.SIMPLE_JWT["AUTH_COOKIE_HTTP_ONLY"],
39+
samesite=settings.SIMPLE_JWT["AUTH_COOKIE_SAMESITE"],
40+
max_age=823396,
41+
)
42+
response.data = data
43+
response.status_code = status.HTTP_200_OK
44+
return response
45+
else:
46+
return Response(
47+
{"details": "This account is not active."},
48+
status=status.HTTP_400_BAD_REQUEST,
49+
)
50+
else:
51+
return Response(
52+
{"details": "Account with given credentials not found."},
53+
status=status.HTTP_400_BAD_REQUEST,
54+
)
55+
56+
57+
class LogoutView(generics.GenericAPIView):
58+
permission_classes = []
59+
authentication_classes = []
60+
serializer_class = None
61+
62+
def post(self, request):
63+
response = Response()
64+
response.set_cookie(
65+
key=settings.SIMPLE_JWT["AUTH_COOKIE"],
66+
max_age=0,
67+
secure=settings.SIMPLE_JWT["AUTH_COOKIE_SECURE"],
68+
expires="Thu, 01 Jan 1970 00:00:00 GMT",
69+
samesite=settings.SIMPLE_JWT["AUTH_COOKIE_SAMESITE"],
70+
)
71+
response.data = {"detail": "Logout successful."}
72+
return response

backend/chitchat/settings.py

+15
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
"""
1212

1313
from pathlib import Path
14+
from datetime import timedelta
1415

1516
# Build paths inside the project like this: BASE_DIR / 'subdir'.
1617
BASE_DIR = Path(__file__).resolve().parent.parent
@@ -48,6 +49,7 @@
4849
"api",
4950
"accounts",
5051
"core",
52+
"authentication",
5153
# Dev tools
5254
"django_extensions",
5355
"drf_yasg",
@@ -147,9 +149,22 @@
147149
# "rest_framework.permissions.IsAuthenticated",
148150
],
149151
"DEFAULT_FILTER_BACKENDS": ["django_filters.rest_framework.DjangoFilterBackend"],
152+
"DEFAULT_AUTHENTICATION_CLASSES": (
153+
"authentication.authenticate.CustomAuthentication",
154+
),
150155
}
151156

152157

153158
CORS_ALLOWED_ORIGINS = [
154159
"http://localhost:5173",
155160
]
161+
162+
SIMPLE_JWT = {
163+
"AUTH_COOKIE": "access_token", # Cookie name. Enables cookies if value is set.
164+
"AUTH_COOKIE_DOMAIN": None, # A string like "example.com", or None for standard domain cookie.
165+
"AUTH_COOKIE_SECURE": True, # Whether the auth cookies should be secure (https:// only).
166+
"AUTH_COOKIE_HTTP_ONLY": True, # Http only cookie flag.It's not fetch by javascript.
167+
"AUTH_COOKIE_PATH": "/", # The path of the auth cookie.
168+
"AUTH_COOKIE_SAMESITE": "None", # Whether to set the flag restricting cookie leaks on cross-site requests.
169+
"ACCESS_TOKEN_LIFETIME": timedelta(days=1),
170+
}

backend/chitchat/urls.py

+1
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
1. Import the include() function: from django.urls import include, path
1515
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
1616
"""
17+
1718
from django.contrib import admin
1819
from django.urls import path, include
1920
from rest_framework import permissions

backend/requirements.txt

62 Bytes
Binary file not shown.

0 commit comments

Comments
 (0)