Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Added authentication endpoints #72

Open
wants to merge 3 commits into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions backend/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@ FROM python:3.10-slim

WORKDIR /app
COPY requirements.txt /app
# 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 \
libc6-dev \
&& rm -rf /var/lib/apt/lists/*
RUN pip install -r requirements.txt
COPY . /app

Expand Down
1 change: 1 addition & 0 deletions backend/api/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,4 +25,5 @@
path("api/posts/<int:pk>/", views.PostDetail.as_view(), name="post-detail"),
path("api/posts/delete/<int:pk>/", views.PostDelete.as_view(), name="post-delete"),
path("api/signup", views.SignUpView.as_view(), name="signup-view"),
path("api/v2/auth/", include("authentication.urls")),
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@maunga-et let's not change the api version yet. I recommend we name the JWT endpoints something like api/token/. Then we can have endpoints like:

  • api/token/ for generating a token keypair or login
  • api/token/refresh/ to generate new refresh tokens and
  • api/token/logout to invalidate & blacklist tokens and then log the user out.

]
Empty file.
3 changes: 3 additions & 0 deletions backend/authentication/admin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from django.contrib import admin

# Register your models here.
6 changes: 6 additions & 0 deletions backend/authentication/apps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
from django.apps import AppConfig


class AuthenticationConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'authentication'
39 changes: 39 additions & 0 deletions backend/authentication/authenticate.py
Original file line number Diff line number Diff line change
@@ -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
Empty file.
3 changes: 3 additions & 0 deletions backend/authentication/models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from django.db import models

# Create your models here.
9 changes: 9 additions & 0 deletions backend/authentication/serializers.py
Original file line number Diff line number Diff line change
@@ -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"]
41 changes: 41 additions & 0 deletions backend/authentication/tests.py
Original file line number Diff line number Diff line change
@@ -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."})
7 changes: 7 additions & 0 deletions backend/authentication/urls.py
Original file line number Diff line number Diff line change
@@ -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'),
]
68 changes: 68 additions & 0 deletions backend/authentication/views.py
Original file line number Diff line number Diff line change
@@ -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'
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's remove these unnecessary comments. We can add these back in later if we need them

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Another point, I just thought of @maunga-et . Let's make the access token valid for 10 mins and then, once we have a refresh token, we can set it to be valid for 2 weeks.

This will allow users to keep logged in without having to reauthenticate as long as they are still using the site.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Okay I am on it thanks.

)
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',
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@maunga-et Same as above, we can remove this comment.

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
15 changes: 15 additions & 0 deletions backend/chitchat/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -48,6 +49,7 @@
"api",
"accounts",
"core",
"authentication",
# Dev tools
"django_extensions",
"drf_yasg",
Expand Down Expand Up @@ -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)
}
Binary file modified backend/requirements.txt
Binary file not shown.
Loading