Skip to content

Commit

Permalink
Fix permissions and authentication
Browse files Browse the repository at this point in the history
  • Loading branch information
okeneo committed Sep 15, 2024
1 parent e800b06 commit 3b4eb41
Show file tree
Hide file tree
Showing 9 changed files with 301 additions and 87 deletions.
51 changes: 51 additions & 0 deletions backend/account/migrations/0001_initial.py
Original file line number Diff line number Diff line change
@@ -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,
),
),
],
),
]
2 changes: 1 addition & 1 deletion backend/account/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
19 changes: 14 additions & 5 deletions backend/account/permissions.py
Original file line number Diff line number Diff line change
@@ -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):
Expand All @@ -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
45 changes: 13 additions & 32 deletions backend/account/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand All @@ -111,7 +101,7 @@ class Meta:
"last_name",
"password1",
"password2",
"profile_data",
"profile",
]

def validate_username(self, username):
Expand Down Expand Up @@ -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)
Expand All @@ -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
31 changes: 12 additions & 19 deletions backend/account/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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"],
Expand All @@ -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)

Expand All @@ -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()
Expand All @@ -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
4 changes: 1 addition & 3 deletions backend/blog/urls.py
Original file line number Diff line number Diff line change
@@ -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")),
Expand Down
23 changes: 1 addition & 22 deletions backend/myproject/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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"

Expand Down
Loading

0 comments on commit 3b4eb41

Please sign in to comment.