Skip to content
Snippets Groups Projects
Unverified Commit dfe08245 authored by sanfordstudent's avatar sanfordstudent Committed by GitHub
Browse files

Merge pull request #17960 from edx/sstudent/educator-2628

EDUCATOR-2628: deactivate and logout user
parents 648b13dc 9e482553
No related merge requests found
......@@ -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)
......@@ -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()
......@@ -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(),
......
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment