davidcr01 / WordlePlus

Repository to store all the documentation, files and structure of my Final Degree Project (TFG in Spanish). The main goal is to develop, as full-stack web developer, a remodel of the Wordle game, including more features and functionalities using Ionic, Django REST Api and PostgreSQL.
1 stars 0 forks source link

Customize the Users authentication (S1) #4

Closed davidcr01 closed 1 year ago

davidcr01 commented 1 year ago

Description

Once the User model and Players models are created and tested (https://github.com/davidcr01/TFG/issues/3) it is necessary to modify the permissions of these users in order to securitize the API methods. Players should have access to a group of API calls and Administrators to another group of API calls. And every non-authenticated user must not have access to any of the API methods.

Some interesting links are:

Tasks

davidcr01 commented 1 year ago

Update Report

Basic authorization

This application will use tokens as the basic authentication method. This type of authentication is stateless, allowing the server not to store session information. It provides scalability, as the server can handle large number of requests without additional overhead. Besides, it is very compatible with an API REST, as they can easily included in the HTTP headers and they can be expired. Finally, tokens do not expose the user password in the request.

After importing the authtokenapplication and configuring our project to enable the Token Authorization, we can get tokens related to our users:


'rest_framework.authtoken',
...

'DEFAULT_AUTHENTICATION_CLASSES': [
        'rest_framework.authentication.TokenAuthentication',
    ],

It is necessary to enable a route path to obtain tokens. path('api-token-auth/', ObtainAuthToken.as_view())

This path allows POST request, with the username and the password as the body, and it returns the associated token. The user must exist in the database.

1

For example, if we allow the list of the users to be authenticated, and the token is not inserted in the head of the request, and it is not valid, an error will be returned:

2

Permission groups

As a first approach, I created two groups to assign them permissions: players and event managers.

# Create a group for the Event Managers (administrators)
event_manager_group, created = Group.objects.get_or_create(name='Event_Manager')

# Create a group for the Players
player_group, created = Group.objects.get_or_create(name='Players')

And every user would be assigned to one of these groups. To protect the API, some checks would be done:

def get_permissions(self):
        """
        Sobrescribe el método `get_permissions` para incluir la validación de grupo.
        """
        if self.action in ['create', 'update', 'partial_update', 'destroy']: 
            return [GroupPermission('Players')]
        else:
            return [permissions.IsAuthenticated()]

But, as the different users are defined (Event Managers and Players are identified with the is_staff field), the creation of these groups is not necessary. The previous logic can be replaced by using custom permissions and checking the role of the user. For example:

class IsOwnerOrAdminPermission(permissions.BasePermission):
    def has_object_permission(self, request, view, obj):
        # Case of comparing an user
        if obj == request.user:
            return True
        # Case of comparing a player, the user information is inside `obj`
        if hasattr(obj, 'user') and obj.user == request.user:
            return True

        if request.user.is_staff:
            return True

        return False

This custom permission checks if the user is the owner of the information or an event manager.

Therefore, this permission is easily used in the viewset of the project. For example:

def get_permissions(self):
        """
        Overwrites the get_permissions method to include the validation of the is_staff field
        """
        if self.action == 'create':
           ...
        elif self.action in ['update', 'partial_update', 'destroy']:
            # Edition and destruction available only for the Event Managers. Needed for the Event Managers
            # to edit the personal info of the players.
            return [IsOwnerOrAdminPermission()]

If we are requesting an update or deletion of a user account, the custom permission will be checked before.

Testing the permissions

First, let define the permissions:

This can easily be traduced to the code, in the views.py file

class CustomUserViewSet(viewsets.ModelViewSet):
    """
    API endpoint that allows users to be viewed or edited.
    """
    queryset = CustomUser.objects.all().order_by('-date_joined')
    serializer_class = CustomUserSerializer

    def get_permissions(self):
        """
        Overwrites the get_permissions method to include the validation of the is_staff field
        """
        if self.action == 'create':
            # User creation is available to every user
            return []
        elif self.action in ['list',]:
            # List available only for the Event Managers.
            return [IsAdminUser()]
        elif self.action in ['update', 'partial_update', 'destroy']:
            # Edition and destruction available only for the Event Managers. Needed for the Event Managers
            # to edit the personal info of the players.
            return [IsOwnerOrAdminPermission()]
        else:
            # Authentication is needed for the rest of the operations.
            return [permissions.IsAuthenticated()]

class PlayerViewSet(viewsets.ModelViewSet):
    """
    API endpoint that allows players to be viewed or edited.
    """
    queryset = Player.objects.all().order_by('wins')
    serializer_class = PlayerSerializer

    def get_permissions(self):
        """
        Overwrites the get_permissions method to include the validation of the is_staff field
        """
        if self.action == 'create':
            # Player creation is available to every user
            return []
        elif self.action in ['update', 'partial_update']:
            # Edition only for the owner or event managers.
            return [IsAdminUser()]
        elif self.action == 'destroy':
            # Destruction available for Admins and the owner
            return [IsOwnerOrAdminPermission()]
        else:
            # Authentication is needed for the rest of the operations.
            return [permissions.IsAuthenticated()]

This permission will be useful in the milestone https://github.com/davidcr01/TFG/milestone/5 The IsAuthenticated and IsAdminUser permissions are included by default in Django.

First, we create a non-staff user that would be a player. The user and player creation can be used as non-authenticated users. This is necessary to allow new users to create an account.

Let's test this user (player) permissions.

After creatin 3 g the account, we get its token:

🟢 The player can list the rest of the players: 4

But it can not list the users: 5

🟢 If the user tries to delete his own account: 6 7

🟢And if he tries to delete another account, he will get an error: 8

🟢 And if the user tries to edit his information (in this case, he is the owner of the account): 9

🟢If an admin tries to edit the information of the related player: 10

Notice that some users' information is returned, and this information was protected against the players. The issue https://github.com/davidcr01/TFG/issues/7 has been created to report this problem and solve it.

Instead, if the owner tries to edit his own player information, it will obtain a 403 Forbidden error: 11

🟢 If a player tries to delete another player's account it will obtain an error: 12

🟢 If he is the owner of the Player account or an Event Manager, the operation will be allowed: 13

Overwriting the Players' destroy method.

This step is necessary if a player deletes its account. By default, the player information is deleted, but the user information remains in the system. To avoid this, is necessary to change the destroy method.

def destroy(self, request, *args, **kwargs):
        instance = self.get_object()

        # Delete the related user
        user = instance.user
        user.delete()

        # Delete the player
        self.perform_destroy(instance)
        return Response(status=status.HTTP_204_NO_CONTENT)

This method fetches the related user of the players, deletes it, and also deletes the players. This is a brief example of how these methods can be modified easily.