From 3b4eb415841d401fa849292fd78c5ad431c75360 Mon Sep 17 00:00:00 2001 From: Oghenetega Okene Date: Sun, 15 Sep 2024 14:49:59 -0500 Subject: [PATCH] Fix permissions and authentication --- backend/account/migrations/0001_initial.py | 51 ++++++ backend/account/models.py | 2 +- backend/account/permissions.py | 19 +- backend/account/serializers.py | 45 ++--- backend/account/views.py | 31 ++-- backend/blog/urls.py | 4 +- backend/myproject/settings.py | 23 +-- backend/post/migrations/0001_initial.py | 197 +++++++++++++++++++++ backend/post/views.py | 16 +- 9 files changed, 301 insertions(+), 87 deletions(-) create mode 100644 backend/account/migrations/0001_initial.py create mode 100644 backend/post/migrations/0001_initial.py diff --git a/backend/account/migrations/0001_initial.py b/backend/account/migrations/0001_initial.py new file mode 100644 index 0000000..3605242 --- /dev/null +++ b/backend/account/migrations/0001_initial.py @@ -0,0 +1,51 @@ +# Generated by Django 5.0 on 2024-09-15 16:11 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="Profile", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("bio", models.CharField(blank=True, max_length=500)), + ( + "role", + models.CharField( + choices=[ + ("AUTHOR", "AUTHOR"), + ("ADMIN", "ADMIN"), + ("READER", "READER"), + ], + default="READER", + max_length=10, + ), + ), + ( + "user", + models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + ), + ), + ], + ), + ] diff --git a/backend/account/models.py b/backend/account/models.py index d29314a..0ffb9a8 100644 --- a/backend/account/models.py +++ b/backend/account/models.py @@ -16,8 +16,8 @@ class Profile(models.Model): (READER, READER), ) user = models.OneToOneField(User, on_delete=models.CASCADE) - bio = models.CharField(max_length=500, blank=True) role = models.CharField(max_length=10, choices=ROLE_CHOICES, default=READER) + bio = models.CharField(max_length=500, blank=True) def __str__(self): return self.user.username diff --git a/backend/account/permissions.py b/backend/account/permissions.py index 09be01f..77a1798 100644 --- a/backend/account/permissions.py +++ b/backend/account/permissions.py @@ -1,6 +1,7 @@ -from django.contrib.auth.models import User from rest_framework.permissions import SAFE_METHODS, BasePermission +from .models import Profile + class ReadOnly(BasePermission): def has_permission(self, request, view): @@ -9,19 +10,27 @@ def has_permission(self, request, view): class IsReader(BasePermission): def has_permission(self, request, view): - return request.user.role == User.READER + return request.user.profile.role == Profile.READER class IsAuthor(BasePermission): def has_permission(self, request, view): - return request.user.role == User.AUTHOR + return request.user.profile.role == Profile.AUTHOR + + def has_object_permission(self, request, view, obj): + return obj.author == request.user class IsAdmin(BasePermission): def has_permission(self, request, view): - return request.user.role == User.ADMIN + return request.user.profile.role == Profile.ADMIN + + +class IsUser(BasePermission): + def has_object_permission(self, request, view, obj): + return obj == request.user -class IsOwner(BasePermission): +class IsOwnerOfObject(BasePermission): def has_object_permission(self, request, view, obj): return obj.user == request.user diff --git a/backend/account/serializers.py b/backend/account/serializers.py index b3ffc00..39e7265 100644 --- a/backend/account/serializers.py +++ b/backend/account/serializers.py @@ -2,7 +2,6 @@ from django.contrib.auth.password_validation import validate_password from django.contrib.auth.validators import UnicodeUsernameValidator from rest_framework import serializers -from rest_framework_simplejwt.serializers import TokenObtainPairSerializer from .models import Profile @@ -69,38 +68,29 @@ def create(self, validated_data): class ProfileSerializer(serializers.ModelSerializer): username = serializers.SerializerMethodField() - bio = serializers.CharField(max_length=500, read_only=True) role = serializers.CharField(max_length=10, read_only=True) + bio = serializers.CharField(max_length=500, allow_blank=True) class Meta: model = Profile fields = [ "username", - "bio", "role", + "bio", ] def get_username(self, obj): return obj.user.username - def to_representation(self, instance): - representation = super().to_representation(instance) - - # Conditionally remove the 'user' field when it's nested inside UserSerializer. - if "request" in self.context and isinstance( - self.context["request"].parser_context["view"], UserSerializer - ): - representation.pop("username", None) - class UserSerializer(serializers.ModelSerializer): username = serializers.CharField(max_length=150) - email = serializers.EmailField() - first_name = serializers.CharField(max_length=150) - last_name = serializers.CharField(max_length=150) + email = serializers.EmailField(allow_blank=True) + first_name = serializers.CharField(max_length=150, allow_blank=True) + last_name = serializers.CharField(max_length=150, allow_blank=True) password1 = serializers.CharField(style={"input_type": "password"}, write_only=True) password2 = serializers.CharField(style={"input_type": "password"}, write_only=True) - profile_data = ProfileSerializer() + profile = ProfileSerializer() class Meta: model = User @@ -111,7 +101,7 @@ class Meta: "last_name", "password1", "password2", - "profile_data", + "profile", ] def validate_username(self, username): @@ -145,9 +135,7 @@ def validate(self, data): return data def update(self, instance, validated_data): - profile_data = validated_data.pop("profile_data") - profile = instance.profile - + # Update User data. instance.username = validated_data.get("username", instance.username) instance.email = validated_data.get("email", instance.email) instance.irst_name = validated_data.get("first_name", instance.first_name) @@ -159,18 +147,11 @@ def update(self, instance, validated_data): instance.save() - profile_data.bio = profile_data.get("bio") - if profile_data.bio: + # Update Profile data. + profile_data = validated_data.pop("profile", None) + profile = instance.profile + if profile_data: + profile.bio = profile_data.get("bio", profile.bio) profile.save() return instance - - -class CustomTokenObtainPairSerializer(TokenObtainPairSerializer): - """A custom serializer that adds 'username' to the payload of a JWT token.""" - - @classmethod - def get_token(cls, user): - token = super().get_token(user) - token["username"] = user.username - return token diff --git a/backend/account/views.py b/backend/account/views.py index f391832..3bb4a59 100644 --- a/backend/account/views.py +++ b/backend/account/views.py @@ -7,16 +7,10 @@ from rest_framework.response import Response from rest_framework.views import APIView from rest_framework_simplejwt.authentication import JWTAuthentication -from rest_framework_simplejwt.views import TokenObtainPairView from .models import Profile -from .permissions import IsAdmin, IsOwner, ReadOnly -from .serializers import ( - CustomTokenObtainPairSerializer, - ProfileSerializer, - UserRegisterSerializer, - UserSerializer, -) +from .permissions import IsAdmin, IsUser, ReadOnly +from .serializers import ProfileSerializer, UserRegisterSerializer, UserSerializer class UserRegisterView(APIView): @@ -55,7 +49,7 @@ def post(self, request, *args, **kwargs): class UserView(APIView): authentication_classes = (JWTAuthentication,) - permission_classes = (ReadOnly | (IsAuthenticated & (IsOwner | IsAdmin)),) + permission_classes = (ReadOnly | (IsAuthenticated & (IsUser | IsAdmin)),) @swagger_auto_schema( tags=["user"], @@ -72,17 +66,18 @@ class UserView(APIView): ) def get(self, request, username, *args, **kwargs): """Get a user's information.""" - user = get_object_or_404(Profile, username=username) + user = get_object_or_404(User, username=username) + profile = user.profile if request.user.is_authenticated: - if request.user == user or request.user.role == Profile.ADMIN: + if request.user == user or request.user.profile.role == Profile.ADMIN: serializer = UserSerializer(user) else: # Use the public serializer for users that do not have the admin role. - serializer = ProfileSerializer(user) + serializer = ProfileSerializer(profile) else: # Use the public serializer for non-authenticated users. - serializer = ProfileSerializer(user) + serializer = ProfileSerializer(profile) return Response(serializer.data, status=status.HTTP_200_OK) @@ -104,6 +99,8 @@ def get(self, request, username, *args, **kwargs): def put(self, request, username, *args, **kwargs): """Update a user's information.""" user = get_object_or_404(User, username=username) + self.check_object_permissions(request, user) + serializer = UserSerializer(user, data=request.data, partial=True) if serializer.is_valid(): serializer.save() @@ -127,11 +124,7 @@ def put(self, request, username, *args, **kwargs): def delete(self, request, username, *args, **kwargs): """Delete a user.""" user = get_object_or_404(User, username=username) + self.check_object_permissions(request, user) + user.delete() return Response({"detail": "User deleted successfully."}, status=status.HTTP_200_OK) - - -class CustomTokenObtainPairView(TokenObtainPairView): - """A custom view for user authentication using JWT.""" - - serializer_class = CustomTokenObtainPairSerializer diff --git a/backend/blog/urls.py b/backend/blog/urls.py index d749409..f856c89 100644 --- a/backend/blog/urls.py +++ b/backend/blog/urls.py @@ -1,12 +1,10 @@ -from account.views import CustomTokenObtainPairView from django.urls import include, path from rest_framework_simplejwt.views import TokenBlacklistView, TokenObtainPairView, TokenRefreshView app_name = "blog" urlpatterns = [ path("users/", include("account.urls")), - path("token/", CustomTokenObtainPairView.as_view(), name="token_obtain_pair"), - path("token-test/", TokenObtainPairView.as_view(), name="token_obtain_pair_test"), + path("token/", TokenObtainPairView.as_view(), name="token_obtain_pair"), path("token/refresh/", TokenRefreshView.as_view(), name="token_refresh"), path("token/blacklist/", TokenBlacklistView.as_view(), name="token_blacklist"), path("posts/", include("post.urls")), diff --git a/backend/myproject/settings.py b/backend/myproject/settings.py index ecee7fc..9569260 100644 --- a/backend/myproject/settings.py +++ b/backend/myproject/settings.py @@ -160,9 +160,8 @@ ] -# Settings to customize the behaviour of Simple JWT. SIMPLE_JWT = { - "ACCESS_TOKEN_LIFETIME": timedelta(minutes=5), + "ACCESS_TOKEN_LIFETIME": timedelta(minutes=60), "REFRESH_TOKEN_LIFETIME": timedelta(days=1), "ROTATE_REFRESH_TOKENS": True, "BLACKLIST_AFTER_ROTATION": True, @@ -187,28 +186,8 @@ "SLIDING_TOKEN_REFRESH_EXP_CLAIM": "refresh_exp", "SLIDING_TOKEN_LIFETIME": timedelta(minutes=5), "SLIDING_TOKEN_REFRESH_LIFETIME": timedelta(days=1), - "TOKEN_OBTAIN_SERIALIZER": "account.serializers.CustomTokenObtainPairSerializer", - "TOKEN_REFRESH_SERIALIZER": "rest_framework_simplejwt.serializers.TokenRefreshSerializer", - "TOKEN_VERIFY_SERIALIZER": "rest_framework_simplejwt.serializers.TokenVerifySerializer", - "TOKEN_BLACKLIST_SERIALIZER": "rest_framework_simplejwt.serializers.TokenBlacklistSerializer", - "SLIDING_TOKEN_OBTAIN_SERIALIZER": "rest_framework_simplejwt.serializers.TokenObtainSlidingSerializer", - "SLIDING_TOKEN_REFRESH_SERIALIZER": "rest_framework_simplejwt.serializers.TokenRefreshSlidingSerializer", } - -# Variables for verifying a new user's email address. -VERIFICATION_EMAIL_TOKEN_EXPIRY_LIFE = 60 * 10 # 10 minutes. -VERIFICATION_EMAIL_TOKEN_MAX_ATTEMPTS = 3 - -# Variables for verifying a user's new email address. -VERIFICATION_EMAIL_UPDATE_TOKEN_EXPIRY_LIFE = 60 * 10 # 10 minutes. - -# Variables allowing a user to reset their password. -VERIFICATION_RESET_PASSWORD_TOKEN_EXPIRY_LIFE = 60 * 10 # 10 minutes. - -# Determines the period in which a user can verify their account during registration. -MAX_TIME_TO_CONFIRM_EMAIL = 3 * 24 * 60 * 60 # 3 days. - # Add username and password to Redis instance before deploying to production. CELERY_BROKER_URL = "redis://redis:6379/0" diff --git a/backend/post/migrations/0001_initial.py b/backend/post/migrations/0001_initial.py new file mode 100644 index 0000000..aca4e3d --- /dev/null +++ b/backend/post/migrations/0001_initial.py @@ -0,0 +1,197 @@ +# Generated by Django 5.0 on 2024-09-15 16:11 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="Category", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "name", + models.CharField( + choices=[("Technology", "Technology"), ("Life", "Life")], + max_length=20, + unique=True, + ), + ), + ], + options={ + "verbose_name_plural": "Categories", + }, + ), + migrations.CreateModel( + name="Tag", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "name", + models.CharField( + choices=[ + ("Software Engineering", "Software Engineering"), + ("Django", "Django"), + ("Grit", "Grit"), + ], + max_length=20, + unique=True, + ), + ), + ], + ), + migrations.CreateModel( + name="Post", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("title", models.CharField(max_length=255, unique=True)), + ("subtitle", models.CharField(blank=True, max_length=255)), + ("slug", models.SlugField(blank=True, max_length=255, unique=True)), + ("body", models.TextField()), + ("meta_description", models.CharField(blank=True, max_length=150)), + ("date_created", models.DateTimeField(auto_now_add=True)), + ("date_modified", models.DateTimeField(auto_now=True)), + ("publish_date", models.DateTimeField(blank=True, null=True)), + ("published", models.BooleanField(default=False)), + ( + "author", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="posts", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "category", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, to="post.category" + ), + ), + ("tags", models.ManyToManyField(blank=True, to="post.tag")), + ], + options={ + "ordering": ["-publish_date"], + }, + ), + migrations.CreateModel( + name="Comment", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("text", models.TextField()), + ("date_created", models.DateTimeField(auto_now_add=True)), + ("date_modified", models.DateTimeField(auto_now=True)), + ("is_deleted", models.BooleanField(default=False)), + ( + "parent_comment", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="replies", + to="post.comment", + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="comments", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "post", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to="post.post" + ), + ), + ], + options={ + "ordering": ["-date_created"], + }, + ), + migrations.CreateModel( + name="Reaction", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "reaction_type", + models.CharField( + choices=[ + ("NEUTRAL", "NEUTRAL"), + ("LIKE", "LIKE"), + ("DISLIKE", "DISLIKE"), + ], + default="NEUTRAL", + max_length=7, + ), + ), + ( + "comment", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to="post.comment" + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="reactions", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "unique_together": {("user", "comment")}, + }, + ), + ] diff --git a/backend/post/views.py b/backend/post/views.py index 840bdc9..28bddd7 100644 --- a/backend/post/views.py +++ b/backend/post/views.py @@ -1,4 +1,4 @@ -from account.permissions import IsAdmin, IsAuthor, IsOwner, ReadOnly +from account.permissions import IsAdmin, IsAuthor, IsOwnerOfObject, ReadOnly from django.utils import timezone from drf_yasg.utils import swagger_auto_schema from rest_framework import status @@ -46,7 +46,6 @@ def post(self, request, *args, **kwargs): The user must be authenticated and be an author. """ - # TODO: They must be creating a post under their account. serializer = PostWriteSerializer(data=request.data) if serializer.is_valid(): serializer.save() @@ -56,7 +55,7 @@ def post(self, request, *args, **kwargs): class PostDetailView(APIView): authentication_classes = (JWTAuthentication,) - permission_classes = (ReadOnly | (IsAuthenticated & (IsAdmin | (IsAuthor & IsOwner))),) + permission_classes = (ReadOnly | (IsAuthenticated & (IsAdmin | IsAuthor)),) @swagger_auto_schema( response={ @@ -85,6 +84,8 @@ def put(self, request, pk, *args, **kwargs): The user must be authenticated and must be an admin or the author of the post. """ post = get_object_or_404(Post, pk=pk) + self.check_object_permissions(request, post) + serializer = PostWriteSerializer(post, data=request.data, partial=True) if serializer.is_valid(): serializer.save() @@ -104,13 +105,15 @@ def delete(self, request, pk, *args, **kwargs): The user must be authenticated and must be an admin or the author of the post. """ post = get_object_or_404(Post, pk=pk) + self.check_object_permissions(request, post) + post.delete() return Response({"detail": "Post deleted successfully."}, status=status.HTTP_200_OK) class PublishPostView(APIView): authentication_classes = (JWTAuthentication,) - permission_classes = (IsAuthenticated & (IsAdmin | (IsAuthor & IsOwner)),) + permission_classes = (IsAuthenticated & (IsAdmin | IsAuthor),) @swagger_auto_schema( responses={ @@ -123,6 +126,8 @@ class PublishPostView(APIView): def post(self, request, pk, *args, **kwargs): """Publish an existing post.""" post = get_object_or_404(Post, pk=pk) + self.check_object_permissions(request, post) + if not post.published: post.published = True post.publish_date = timezone.now() @@ -159,7 +164,7 @@ def post(self, request, pk, *args, **kwargs): class CommentDetailView(APIView): authentication_classes = (JWTAuthentication,) - permission_classes = (ReadOnly | (IsAuthenticated & IsOwner),) + permission_classes = (ReadOnly | (IsAuthenticated & IsOwnerOfObject),) def get(self, request, pk, *args, **kwargs): pass @@ -177,6 +182,7 @@ def put(self, request, pk, *args, **kwargs): def delete(self, request, pk, *args, **kwargs): """Delete a comment.""" comment = get_object_or_404(Comment, pk=pk) + self.check_object_permissions(request, comment) if comment.replies.exist(): comment.soft_delete()