diff --git a/Rasaddam_Backend/settings.py b/Rasaddam_Backend/settings.py index 028fbd1..07d71e7 100644 --- a/Rasaddam_Backend/settings.py +++ b/Rasaddam_Backend/settings.py @@ -80,6 +80,7 @@ MIDDLEWARE = [ 'django.contrib.auth.middleware.AuthenticationMiddleware', 'django.contrib.messages.middleware.MessageMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware', + 'apps.authentication.middlewares.BlockedTokenMiddleware', 'crum.CurrentRequestUserMiddleware', 'apps.log.middlewares.SaveLog' ] diff --git a/apps/authentication/api/v1/api.py b/apps/authentication/api/v1/api.py index 18880b4..640d76b 100644 --- a/apps/authentication/api/v1/api.py +++ b/apps/authentication/api/v1/api.py @@ -10,7 +10,7 @@ from apps.authentication.api.v1.serializers.serializer import ( OrganizationTypeSerializer, OrganizationSerializer, UserSerializer, - BankAccountSerializer + BankAccountSerializer, ) from rest_framework_simplejwt.views import TokenObtainPairView from apps.authorization.api.v1 import api as authorize_view @@ -21,7 +21,8 @@ from apps.authentication.models import ( Province, Organization, OrganizationType, - BankAccountInformation + BankAccountInformation, + BlacklistedAccessToken ) from django.db import transaction from rest_framework.response import Response @@ -30,6 +31,9 @@ from django.core.cache import cache from rest_framework import status from common.sms import send_sms import random +from rest_framework.views import APIView +from rest_framework.permissions import IsAuthenticated +from apps.authentication.tools import get_token_jti class CustomizedTokenObtainPairView(TokenObtainPairView): @@ -37,6 +41,24 @@ class CustomizedTokenObtainPairView(TokenObtainPairView): serializer_class = CustomizedTokenObtainPairSerializer +class LogoutView(APIView): + permission_classes = [IsAuthenticated] + + def post(self, request): + token_str = request.auth # access token from header + jti, user_id = get_token_jti(str(token_str)) + + if not jti: + return Response({'detail': 'Invalid token'}, status=status.HTTP_400_BAD_REQUEST) + + BlacklistedAccessToken.objects.get_or_create(jti=jti, defaults={ + 'token': token_str, + 'user_id': user_id, + }) + + return Response({'detail': 'Access token blacklisted.'}, status=status.HTTP_200_OK) + + class UserViewSet(ModelViewSet): """ Crud operations for user model """ queryset = User.objects.all() diff --git a/apps/authentication/api/v1/urls.py b/apps/authentication/api/v1/urls.py index ecf0d2c..cb8d41c 100644 --- a/apps/authentication/api/v1/urls.py +++ b/apps/authentication/api/v1/urls.py @@ -13,7 +13,8 @@ from .api import ( ProvinceViewSet, OrganizationViewSet, OrganizationTypeViewSet, - GeneralOTPViewSet + GeneralOTPViewSet, + LogoutView ) router = DefaultRouter() @@ -26,6 +27,7 @@ router.register(r'otp', GeneralOTPViewSet, basename='otp') urlpatterns = [ path('login/', CustomizedTokenObtainPairView.as_view(), name='token_obtain_pair'), + path('logout/', LogoutView.as_view(), name='logut'), path('token/refresh/', TokenRefreshView.as_view(), name='token_refresh'), path('token/verify/', TokenVerifyView.as_view(), name='token_verify'), path('token/revoke/', TokenBlacklistView.as_view(), name='revoke_token'), diff --git a/apps/authentication/exceptions.py b/apps/authentication/exceptions.py new file mode 100644 index 0000000..76d8a87 --- /dev/null +++ b/apps/authentication/exceptions.py @@ -0,0 +1,9 @@ +from rest_framework.exceptions import APIException +from django.utils.translation import gettext_lazy as _ +from rest_framework import status + + +class TokenBlackListedException(APIException): + status_code = status.HTTP_401_UNAUTHORIZED + default_detail = _('unauthorized') + default_code = 'unauthorized' diff --git a/apps/authentication/middlewares.py b/apps/authentication/middlewares.py new file mode 100644 index 0000000..f8dfbff --- /dev/null +++ b/apps/authentication/middlewares.py @@ -0,0 +1,25 @@ +from django.utils.deprecation import MiddlewareMixin +from .models import BlacklistedAccessToken +from apps.authentication.tools import get_token_jti +from rest_framework.exceptions import AuthenticationFailed +from apps.authentication.exceptions import TokenBlackListedException +from rest_framework.response import Response +from django.http import JsonResponse +from rest_framework import status + + +class BlockedTokenMiddleware: + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + auth_header = request.headers.get('Authorization') + if auth_header and auth_header.startswith('Bearer '): + token_str = auth_header[7:] + jti, _ = get_token_jti(token_str) + if jti and BlacklistedAccessToken.objects.filter(jti=jti).exists(): + return JsonResponse({ + 'detail': 'Access token has been blacklisted' + }, status=401) + + return self.get_response(request) diff --git a/apps/authentication/migrations/0020_blacklistedaccesstoken.py b/apps/authentication/migrations/0020_blacklistedaccesstoken.py new file mode 100644 index 0000000..9c52f23 --- /dev/null +++ b/apps/authentication/migrations/0020_blacklistedaccesstoken.py @@ -0,0 +1,23 @@ +# Generated by Django 5.0 on 2025-06-02 08:31 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('authentication', '0019_organizationtype_code'), + ] + + operations = [ + migrations.CreateModel( + name='BlacklistedAccessToken', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('jti', models.CharField(max_length=255, unique=True)), + ('token', models.TextField()), + ('user_id', models.IntegerField()), + ('blacklisted_at', models.DateTimeField(auto_now_add=True)), + ], + ), + ] diff --git a/apps/authentication/models.py b/apps/authentication/models.py index 0c30eca..0edfa25 100644 --- a/apps/authentication/models.py +++ b/apps/authentication/models.py @@ -144,3 +144,13 @@ class BankAccountInformation(BaseModel): def save(self, *args, **kwargs): super(BankAccountInformation, self).save(*args, **kwargs) + + +class BlacklistedAccessToken(models.Model): + jti = models.CharField(max_length=255, unique=True) + token = models.TextField() + user_id = models.IntegerField() + blacklisted_at = models.DateTimeField(auto_now_add=True) + + def __str__(self): + return f"Blacklisted JTI: {self.jti}" diff --git a/apps/authentication/tools.py b/apps/authentication/tools.py new file mode 100644 index 0000000..f615271 --- /dev/null +++ b/apps/authentication/tools.py @@ -0,0 +1,9 @@ +from rest_framework_simplejwt.tokens import AccessToken + + +def get_token_jti(token_str): + try: + token = AccessToken(token_str) + return token['jti'], token['user_id'] + except Exception as e: + return None, None diff --git a/apps/authorization/api/v1/api.py b/apps/authorization/api/v1/api.py index 622fab4..26b7709 100644 --- a/apps/authorization/api/v1/api.py +++ b/apps/authorization/api/v1/api.py @@ -3,14 +3,16 @@ from rest_framework.permissions import AllowAny, IsAuthenticated from apps.authorization.api.v1.serializers import ( RoleSerializer, PermissionSerializer, - UserRelationSerializer + UserRelationSerializer, + PageSerializer ) from rest_framework.decorators import action from rest_framework.response import Response from apps.authorization.models import ( Role, Permissions, - UserRelations + UserRelations, + Page ) from rest_framework import viewsets from django.db import transaction @@ -23,27 +25,22 @@ class RoleViewSet(viewsets.ModelViewSet): serializer_class = RoleSerializer +class PageViewSet(viewsets.ModelViewSet): + """ add website pages to system to set permission on it """ + + queryset = Page.objects.all() + serializer_class = PageSerializer + + class PermissionViewSet(viewsets.ModelViewSet): """ Crud Operations for Permissions """ queryset = Permissions.objects.all() serializer_class = PermissionSerializer - @action( - methods=['get'], - detail=False, - url_path='get_user_permissions', - url_name='get_user_permissions', - name='get_user_permissions' - ) - @transaction.atomic - def get_user_permissions(self, request): - pass - class UserRelationViewSet(viewsets.ModelViewSet): """ Crud Operations for User Relations """ queryset = UserRelations.objects.all() serializer_class = UserRelationSerializer - diff --git a/apps/authorization/api/v1/serializers.py b/apps/authorization/api/v1/serializers.py index 329d0e5..03b6e58 100644 --- a/apps/authorization/api/v1/serializers.py +++ b/apps/authorization/api/v1/serializers.py @@ -1,14 +1,26 @@ +import typing + from rest_framework import serializers from apps.authorization.models import ( Role, Permissions, - UserRelations + UserRelations, + Page ) from apps.authentication.api.v1.serializers import serializer as auth_serializer from apps.authentication.models import Organization import itertools +class PageSerializer(serializers.ModelSerializer): + class Meta: + model = Page + fields = [ + 'name', + 'code' + ] + + class PermissionSerializer(serializers.ModelSerializer): class Meta: model = Permissions @@ -26,6 +38,18 @@ class PermissionSerializer(serializers.ModelSerializer): return representation + @classmethod + def permissions_structure_output(cls, permissions: list) -> typing.Any: + """ set a structure for permissions """ + structure = {} + for permission in permissions: + if permission.page.name not in structure.keys(): + structure.update( + {f'{permission.page.name}': itertools.chain(*list( + permission.page.permission_page.all().values_list('name'))) + }) + return structure + class RoleSerializer(serializers.ModelSerializer): class Meta: @@ -49,15 +73,7 @@ class RoleSerializer(serializers.ModelSerializer): representation['type'] = auth_serializer.OrganizationTypeSerializer(instance.type).data if instance.permissions: # noqa permissions = instance.permissions.all() - pages = {} - for permission in permissions: - if permission.page.name not in pages.keys(): - pages.update({ - f'{permission.page.name}': itertools.chain(*list( - (permission.page.permission_page.all().values_list('name')) - )) - }) - representation['permissions'] = pages + representation['permissions'] = PermissionSerializer().permissions_structure_output(permissions) return representation @@ -73,6 +89,7 @@ class UserRelationSerializer(serializers.ModelSerializer): ] def to_representation(self, instance): + """ custom output for serializer """ representation = super().to_representation(instance) if isinstance(instance, UserRelations): if instance.user: @@ -81,18 +98,11 @@ class UserRelationSerializer(serializers.ModelSerializer): representation['organization'] = auth_serializer.OrganizationSerializer(instance.organization).data if instance.role: representation['role'] = RoleSerializer(instance.role).data - if instance.permissions: + if instance.permissions: # noqa + # set permissions by a default structure like: + # 'page permission':[element permissions] permissions = instance.permissions.all() - pages = {} - for permission in permissions: - if permission.page.name not in pages.keys(): - pages.update({ - f'{permission.page.name}': itertools.chain(*list( - (permission.page.permission_page.all().values_list('name')) - )) - }) - representation['permissions'] = pages - + representation['permissions'] = PermissionSerializer().permissions_structure_output(permissions) return representation def update(self, instance, validated_data): @@ -100,9 +110,9 @@ class UserRelationSerializer(serializers.ModelSerializer): if validated_data.get('role'): instance.role = Role.objects.get(id=validated_data.get('role', instance.role)) if validated_data.get('organization'): - instance.organization = Organization.objects.get(id=validated_data.get( - 'organization', instance.organization - )) + instance.organization = Organization.objects.get( + id=validated_data.get('organization', instance.organization) + ) instance.save() instance.permissions.clear() instance.permissions.add(*(validated_data.get('permissions', instance.permissions))) diff --git a/apps/authorization/api/v1/urls.py b/apps/authorization/api/v1/urls.py index b1a9043..3f398f7 100644 --- a/apps/authorization/api/v1/urls.py +++ b/apps/authorization/api/v1/urls.py @@ -3,7 +3,8 @@ from django.urls import path, include from .api import ( RoleViewSet, PermissionViewSet, - UserRelationViewSet + UserRelationViewSet, + PageViewSet ) router = DefaultRouter() # set router @@ -12,6 +13,7 @@ router = DefaultRouter() # set router router.register(r'role', RoleViewSet, basename='role') router.register(r'permission', PermissionViewSet, basename='permission') router.register(r'user-relations', UserRelationViewSet, basename='organization-role') +router.register(r'page', PageViewSet, basename='page') urlpatterns = [ path('', include(router.urls))