diff --git a/openedx/core/djangoapps/user_api/accounts/tests/test_views.py b/openedx/core/djangoapps/user_api/accounts/tests/test_views.py index 98fdfdbf4a705db3c8a367d34096f0ca0cd7923d..c7aba4a673a992c76c0581c86fbab7f516e0ad16 100644 --- a/openedx/core/djangoapps/user_api/accounts/tests/test_views.py +++ b/openedx/core/djangoapps/user_api/accounts/tests/test_views.py @@ -11,6 +11,7 @@ from copy import deepcopy import ddt import pytest from django.conf import settings +from django.contrib.auth.models import User from django.core.urlresolvers import reverse from django.test import TestCase from django.test.testcases import TransactionTestCase @@ -20,6 +21,7 @@ from nose.plugins.attrib import attr from pytz import UTC from rest_framework import status from rest_framework.test import APIClient, APITestCase +from social_django.models import UserSocialAuth from openedx.core.djangoapps.user_api.accounts import ACCOUNT_VISIBILITY_PREF_KEY from openedx.core.djangoapps.user_api.accounts.signals import USER_RETIRE_MAILINGS @@ -27,7 +29,7 @@ from openedx.core.djangoapps.user_api.models import UserPreference, UserOrgTag from openedx.core.djangoapps.user_api.preferences.api import set_user_preference from openedx.core.djangolib.testing.utils import CacheIsolationTestCase, skip_unless_lms from openedx.core.lib.token_utils import JwtBuilder -from student.models import PendingEmailChange, UserProfile, get_retired_username_by_username +from student.models import PendingEmailChange, UserProfile, get_retired_username_by_username, get_retired_email_by_email from student.tests.factories import ( TEST_PASSWORD, ContentTypeFactory, @@ -1012,3 +1014,77 @@ class TestAccountRetireMailings(TestCase): ) finally: USER_RETIRE_MAILINGS.disconnect(mock_handler) + + +@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Account APIs are only supported in LMS') +class TestDeactivateLogout(TestCase): + """ + Tests the account deactivation/logout endpoint. + """ + def setUp(self): + super(TestDeactivateLogout, self).setUp() + self.test_user = UserFactory() + self.test_superuser = SuperuserFactory() + self.test_service_user = UserFactory() + + UserSocialAuth.objects.create( + user=self.test_user, + provider='some_provider_name', + uid='xyz@gmail.com' + ) + UserSocialAuth.objects.create( + user=self.test_user, + provider='some_other_provider_name', + uid='xyz@gmail.com' + ) + + self.url = reverse('deactivate_logout') + + def build_jwt_headers(self, user): + """ + Helper function for creating headers for the JWT authentication. + """ + token = JwtBuilder(user).build_token([]) + headers = { + 'HTTP_AUTHORIZATION': 'JWT ' + token + } + return headers + + def build_post(self, username): + return {'user': username} + + def test_superuser_deactivates_user(self): + """ + Verify a superuser calling the deactivation endpoint logs out a user and deletes all their SSO tokens. + """ + headers = self.build_jwt_headers(self.test_superuser) + response = self.client.post(self.url, self.build_post(self.test_user.username), **headers) + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + updated_user = User.objects.get(id=self.test_user.id) + self.assertEqual(get_retired_email_by_email(self.test_user.email), updated_user.email) + self.assertFalse(updated_user.has_usable_password()) + self.assertEqual(list(UserSocialAuth.objects.filter(user=self.test_user)), []) + + def test_unauthorized_rejection(self): + """ + Verify unauthorized users cannot deactivate other users. + """ + headers = self.build_jwt_headers(self.test_user) + response = self.client.post(self.url, self.build_post(self.test_user.username), **headers) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_nonexistent_user(self): + """ + Verify that trying to deactivate a nonexistent user returns a 404. + """ + headers = self.build_jwt_headers(self.test_superuser) + response = self.client.post(self.url, self.build_post("made_up_username"), **headers) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + def test_user_not_specified(self): + """ + Verify that not specifying a user to the deactivation endpoint results in a 404. + """ + headers = self.build_jwt_headers(self.test_superuser) + response = self.client.post(self.url, self.build_post(""), **headers) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) diff --git a/openedx/core/djangoapps/user_api/accounts/views.py b/openedx/core/djangoapps/user_api/accounts/views.py index 1f3036a40a06ee18e2ce54eec70ee1a63b965f25..3dc396a74b27e4b38c22adf01e5e0af4a97249b0 100644 --- a/openedx/core/djangoapps/user_api/accounts/views.py +++ b/openedx/core/djangoapps/user_api/accounts/views.py @@ -5,8 +5,8 @@ For additional information and historical context, see: https://openedx.atlassian.net/wiki/display/TNL/User+API """ -from django.db import transaction from django.contrib.auth import get_user_model +from django.db import transaction from edx_rest_framework_extensions.authentication import JwtAuthentication from rest_framework import permissions from rest_framework import status @@ -14,6 +14,7 @@ from rest_framework.response import Response from rest_framework.views import APIView from rest_framework.viewsets import ViewSet from six import text_type +from social_django.models import UserSocialAuth from openedx.core.djangoapps.user_api.preferences.api import update_email_opt_in from openedx.core.lib.api.authentication import ( @@ -21,7 +22,7 @@ from openedx.core.lib.api.authentication import ( OAuth2AuthenticationAllowInactiveUser, ) from openedx.core.lib.api.parsers import MergePatchParser -from student.models import User, get_potentially_retired_user_by_username_and_hash +from student.models import User, get_potentially_retired_user_by_username_and_hash, get_retired_email_by_email from .api import get_account_settings, update_account_settings from .permissions import CanDeactivateUser, CanRetireUser @@ -250,11 +251,8 @@ class AccountDeactivationView(APIView): Marks the user as having no password set for deactivation purposes. """ - user = User.objects.get(username=username) - user.set_unusable_password() - user.save() - account_settings = get_account_settings(request, [username])[0] - return Response(account_settings) + _set_unusable_password(User.objects.get(username=username)) + return Response(get_account_settings(request, [username])[0]) class AccountRetireMailingsView(APIView): @@ -294,3 +292,85 @@ class AccountRetireMailingsView(APIView): return Response(text_type(exc), status=status.HTTP_500_INTERNAL_SERVER_ERROR) return Response(status=status.HTTP_204_NO_CONTENT) + + +class DeactivateLogoutView(APIView): + """ + POST /api/user/v1/accounts/deactivate_logout/ + { + "user": "example_username", + } + + **POST Parameters** + + A POST request must include the following parameter. + + * user: Required. The username of the user being deactivated. + + **POST Response Values** + + If the request does not specify a username or submits a username + for a non-existent user, the request returns an HTTP 404 "Not Found" + response. + + If a user who is not a superuser tries to deactivate a user, + the request returns an HTTP 403 "Forbidden" response. + + If the specified user is successfully deactivated, the request + returns an HTTP 204 "No Content" response. + + If an unanticipated error occurs, the request returns an + HTTP 500 "Internal Server Error" response. + + Allows an administrative user to take the following actions + on behalf of an LMS user: + - Change the user's password permanently to Django's unusable password + - Log the user out + """ + authentication_classes = (JwtAuthentication, ) + permission_classes = (permissions.IsAuthenticated, CanRetireUser) + + def post(self, request): + """ + POST /api/user/v1/accounts/deactivate_logout + + Marks the user as having no password set for deactivation purposes, + and logs the user out. + """ + username = request.data.get('user', None) + if not username: + return Response( + status=status.HTTP_404_NOT_FOUND, + data={ + 'message': u'The user was not specified.' + } + ) + + user_model = get_user_model() + try: + # make sure the specified user exists + user = user_model.objects.get(username=username) + + with transaction.atomic(): + # 1. Unlink LMS social auth accounts + UserSocialAuth.objects.filter(user_id=user.id).delete() + # 2. Change LMS password & email + user.email = get_retired_email_by_email(user.email) + user.save() + _set_unusable_password(user) + # 3. Unlink social accounts & change password on each IDA, still to be implemented + except user_model.DoesNotExist: + return Response(status=status.HTTP_404_NOT_FOUND) + except Exception as exc: # pylint: disable=broad-except + return Response(text_type(exc), status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + return Response(status=status.HTTP_204_NO_CONTENT) + + +def _set_unusable_password(user): + """ + Helper method for the shared functionality of setting a user's + password to the unusable password, thus deactivating the account. + """ + user.set_unusable_password() + user.save() diff --git a/openedx/core/djangoapps/user_api/urls.py b/openedx/core/djangoapps/user_api/urls.py index ba4729d59102390868403b859ac791627de910f9..f94dd12c6b628ddbea4a1a5050e4866886cf7589 100644 --- a/openedx/core/djangoapps/user_api/urls.py +++ b/openedx/core/djangoapps/user_api/urls.py @@ -6,7 +6,7 @@ from django.conf import settings from django.conf.urls import url from ..profile_images.views import ProfileImageView -from .accounts.views import AccountDeactivationView, AccountRetireMailingsView, AccountViewSet +from .accounts.views import AccountDeactivationView, AccountRetireMailingsView, AccountViewSet, DeactivateLogoutView from .preferences.views import PreferencesDetailView, PreferencesView from .verification_api.views import PhotoVerificationStatusView from .validation.views import RegistrationValidationView @@ -55,6 +55,11 @@ urlpatterns = [ AccountRetireMailingsView.as_view(), name='accounts_retire_mailings' ), + url( + r'^v1/accounts/deactivate_logout/$', + DeactivateLogoutView.as_view(), + name='deactivate_logout' + ), url( r'^v1/accounts/{}/verification_status/$'.format(settings.USERNAME_PATTERN), PhotoVerificationStatusView.as_view(),