EDUCATOR-2628: deactivate and logout user
This commit is contained in:
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user