diff --git a/core/serializers/common_serializers.py b/core/serializers/common_serializers.py index badfb88f7555fde47b8c0fd2694da6a4c440c92e..305b2c6cb2cad6f368cc547f9ea9ee00d210a686 100644 --- a/core/serializers/common_serializers.py +++ b/core/serializers/common_serializers.py @@ -1,5 +1,9 @@ import logging +import django.contrib.auth.password_validation as validators +from django.core import exceptions +from rest_framework import serializers + from core import models from .generic import DynamicFieldsModelSerializer @@ -39,3 +43,23 @@ class SubmissionTypeSerializer(SubmissionTypeListSerializer): 'description', 'solution', 'programming_language') + + +class UserAccountSerializer(DynamicFieldsModelSerializer): + + def validate(self, data): + password = data.get('password') + + try: + if password is not None: + validators.validate_password(password=password, + user=self.instance) + except exceptions.ValidationError as err: + raise serializers.ValidationError({'password': list(err.messages)}) + return data + + class Meta: + model = models.UserAccount + fields = ('pk', 'username', 'role', 'is_admin', 'password') + read_only_fields = ('pk', 'username', 'role', 'is_admin') + extra_kwargs = {'password': {'write_only': True}} diff --git a/core/tests/test_user_account_views.py b/core/tests/test_user_account_views.py new file mode 100644 index 0000000000000000000000000000000000000000..705c6d30380fc1f3fb38cfb6c14dd6a378e020f8 --- /dev/null +++ b/core/tests/test_user_account_views.py @@ -0,0 +1,83 @@ +from rest_framework import status +from rest_framework.test import (APIClient, APITestCase) + +from util.factories import GradyUserFactory + + +class TutorReviewerCanChangePasswordTests(APITestCase): + @classmethod + def setUpTestData(cls): + cls.user_factory = GradyUserFactory() + cls.data = { + 'old_password': 'l', + 'new_password': 'p' + } + + def setUp(self): + self.reviewer = self.user_factory.make_reviewer(password='l') + self.tutor1 = self.user_factory.make_tutor(password='l') + self.tutor2 = self.user_factory.make_tutor(password='l') + self.client = APIClient() + + def _change_password(self, changing_user, user_to_change=None, data=None): + if user_to_change is None: + user_to_change = changing_user + if data is None: + data = self.data + + self.client.force_authenticate(user=changing_user) + url = f"/api/user/{user_to_change.pk}/change_password/" + return self.client.patch(url, data=data) + + def test_tutor_needs_to_provide_current_password(self): + response = self._change_password(self.tutor1, + data={'new_password': 'p'}) + self.assertEqual(status.HTTP_401_UNAUTHORIZED, response.status_code) + ret = self.client.login(username=self.tutor1.username, + password='p') + self.assertFalse(ret) + + def test_reviewer_needs_to_provide_current_password_for_self(self): + response = self._change_password(self.reviewer, + data={'new_password': 'p'}) + self.assertEqual(status.HTTP_401_UNAUTHORIZED, response.status_code) + ret = self.client.login(username=self.tutor1.username, + password='p') + self.assertFalse(ret) + + def test_tutor_can_change_own_password(self): + response = self._change_password(self.tutor1) + self.assertEqual(status.HTTP_200_OK, response.status_code) + ret = self.client.login(username=self.tutor1.username, + password='p') + self.assertTrue(ret) + + def test_tutor_cant_change_other_password(self): + response = self._change_password(self.tutor1, self.tutor2) + self.assertEqual(status.HTTP_403_FORBIDDEN, response.status_code) + ret = self.client.login(username=self.tutor2.username, + password='p') + self.assertFalse(ret) + + def test_reviewer_can_change_own_password(self): + response = self._change_password(self.reviewer) + self.assertEqual(status.HTTP_200_OK, response.status_code) + ret = self.client.login(username=self.reviewer.username, + password='p') + self.assertTrue(ret) + + def test_reviewer_can_change_tutor_password(self): + response = self._change_password(self.reviewer, self.tutor1, + data={'new_password': 'p'}) + self.assertEqual(status.HTTP_200_OK, response.status_code) + ret = self.client.login(username=self.tutor1.username, + password='p') + self.assertTrue(ret) + + def test_student_cant_change_password(self): + student = self.user_factory.make_student(password='l') + response = self._change_password(student) + self.assertEqual(status.HTTP_403_FORBIDDEN, response.status_code) + ret = self.client.login(username=student.username, + password='p') + self.assertFalse(ret) diff --git a/core/urls.py b/core/urls.py index 715379d0180a2e4d97bb6d89cddb268d87d80817..6772c660a2c57b4babb356f36c9b2f643dc86c50 100644 --- a/core/urls.py +++ b/core/urls.py @@ -18,6 +18,7 @@ router.register('subscription', views.SubscriptionApiViewSet, base_name='subscription') router.register('assignment', views.AssignmentApiViewSet) router.register('statistics', views.StatisticsEndpoint, base_name='statistics') +router.register('user', views.UserAccountViewSet, base_name='user') # regular views that are not viewsets regular_views_urlpatterns = [ diff --git a/core/views/common_views.py b/core/views/common_views.py index 5a28fbc84b0b16af0824f6252b6fea4ebd6c5c75..c3201c806958a8d316e318659d6901b5c0b79ced 100644 --- a/core/views/common_views.py +++ b/core/views/common_views.py @@ -4,9 +4,14 @@ user to be authenticated and most are only accessible by one user group """ import logging from django.conf import settings -from django.db.models import Avg +from django.contrib.auth.hashers import check_password +from django.db.models import Avg, Q +import django.contrib.auth.password_validation as validators +from django.core import exceptions + from rest_framework import generics, mixins, status, viewsets -from rest_framework.decorators import api_view, list_route, throttle_classes +from rest_framework.decorators import (api_view, list_route, throttle_classes, + detail_route) from rest_framework.exceptions import PermissionDenied from rest_framework.permissions import AllowAny from rest_framework.response import Response @@ -18,7 +23,8 @@ from core.permissions import IsReviewer, IsStudent, IsTutorOrReviewer from core.serializers import (ExamSerializer, StudentInfoSerializer, StudentInfoSerializerForListView, SubmissionNoTypeSerializer, SubmissionSerializer, - SubmissionTypeSerializer, TutorSerializer) + SubmissionTypeSerializer, TutorSerializer, + UserAccountSerializer) log = logging.getLogger(__name__) @@ -97,7 +103,7 @@ class TutorApiViewSet( mixins.DestroyModelMixin, mixins.ListModelMixin, viewsets.GenericViewSet): - """ Api endpoint for creating, listing, viewing or deleteing tutors """ + """ Api endpoint for creating, listing, viewing or deleting tutors """ permission_classes = (IsReviewer,) queryset = models.UserAccount.tutors \ .with_feedback_count() \ @@ -165,3 +171,43 @@ class SubmissionViewSet(viewsets.ReadOnlyModelViewSet): return self.queryset.filter( assignments__subscription__owner=self.request.user ) + + +class UserAccountViewSet(viewsets.ReadOnlyModelViewSet): + serializer_class = UserAccountSerializer + queryset = models.UserAccount.objects.all() + + @detail_route(methods=['patch'], permission_classes=(IsTutorOrReviewer, )) + def change_password(self, request, *args, **kwargs): + user = self.get_object() + if request.user != user and not request.user.is_reviewer(): + return Response(status=status.HTTP_403_FORBIDDEN) + old_password = request.data.get('old_password') + + # tutors must always provide their current password + # reviewers must provide their current password when they change + # their own, not if they change the password of a tutor + if (request.user.is_tutor() or + request.user.is_reviewer and request.user == user) \ + and \ + (old_password is None or + not check_password(old_password, user.password)): + return Response(status=status.HTTP_401_UNAUTHORIZED) + + new_password = request.data.get('new_password') + # validate password + try: + if new_password is not None: + validators.validate_password(password=new_password, user=user) + except exceptions.ValidationError as err: + return Response({'new_password': list(err.messages)}, + status=status.HTTP_406_NOT_ACCEPTABLE) + user.set_password(new_password) + user.save() + log.info(f"User {request.user} changed password of {user}") + return Response(status=status.HTTP_200_OK) + + @list_route() + def me(self, request): + serializer = self.get_serializer(request.user) + return Response(serializer.data, status=status.HTTP_200_OK)