feat: Support Apple team Migration (#31861)
* feat: LEARNER-8790 Support Apple team migration
This commit is contained in:
@@ -14,6 +14,7 @@ from django.utils.translation import gettext_lazy as _
|
||||
from .models import (
|
||||
_PSA_OAUTH2_BACKENDS,
|
||||
_PSA_SAML_BACKENDS,
|
||||
AppleMigrationUserIdInfo,
|
||||
LTIProviderConfig,
|
||||
OAuth2ProviderConfig,
|
||||
SAMLConfiguration,
|
||||
@@ -196,3 +197,10 @@ class LTIProviderConfigAdmin(KeyedConfigurationModelAdmin):
|
||||
)
|
||||
|
||||
admin.site.register(LTIProviderConfig, LTIProviderConfigAdmin)
|
||||
|
||||
|
||||
class AppleMigrationUserIdInfoAdmin(admin.ModelAdmin):
|
||||
""" Django Admin class for AppleMigrationUserIdInfo """
|
||||
|
||||
|
||||
admin.site.register(AppleMigrationUserIdInfo, AppleMigrationUserIdInfoAdmin)
|
||||
|
||||
@@ -78,8 +78,12 @@ import jwt
|
||||
from jwt.algorithms import RSAAlgorithm
|
||||
from jwt.exceptions import PyJWTError
|
||||
|
||||
from django.apps import apps
|
||||
from social_core.backends.oauth import BaseOAuth2
|
||||
from social_core.exceptions import AuthFailed
|
||||
import social_django
|
||||
|
||||
from common.djangoapps.third_party_auth.toggles import is_apple_user_migration_enabled
|
||||
|
||||
|
||||
class AppleIdAuth(BaseOAuth2):
|
||||
@@ -205,6 +209,33 @@ class AppleIdAuth(BaseOAuth2):
|
||||
|
||||
return user_details
|
||||
|
||||
def get_user_id(self, details, response):
|
||||
"""
|
||||
If Apple team has been migrated, return the correct team_scoped apple_id that matches
|
||||
existing UserSocialAuth instance. Else return apple_id as received in response.
|
||||
"""
|
||||
apple_id = super().get_user_id(details, response)
|
||||
|
||||
if is_apple_user_migration_enabled():
|
||||
if social_django.models.DjangoStorage.user.get_social_auth(provider=self.name, uid=apple_id):
|
||||
return apple_id
|
||||
|
||||
transfer_sub = response.get('transfer_sub')
|
||||
if transfer_sub:
|
||||
# Apple will send a transfer_sub till 60 days after the Apple Team has been migrated.
|
||||
# If the team has been migrated and UserSocialAuth entries have not yet been updated
|
||||
# with the new team-scoped apple-ids', use the transfer_sub to match to old apple ids'
|
||||
# belonging to already signed-in users.
|
||||
AppleMigrationUserIdInfo = apps.get_model('third_party_auth', 'AppleMigrationUserIdInfo')
|
||||
user_apple_id_info = AppleMigrationUserIdInfo.objects.filter(transfer_id=transfer_sub).first()
|
||||
old_apple_id = user_apple_id_info.old_apple_id
|
||||
if social_django.models.DjangoStorage.user.get_social_auth(provider=self.name, uid=old_apple_id):
|
||||
user_apple_id_info.new_apple_id = response.get(self.ID_KEY)
|
||||
user_apple_id_info.save()
|
||||
return user_apple_id_info.old_apple_id
|
||||
|
||||
return apple_id
|
||||
|
||||
def do_auth(self, access_token, *args, **kwargs):
|
||||
response = kwargs.pop('response', None) or {}
|
||||
jwt_string = response.get(self.TOKEN_KEY) or access_token
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
Migrating Apple users while switching teams on Apple
|
||||
-----------------------------------------------
|
||||
|
||||
This document explains how to migrate apple signed-in users in the event of
|
||||
switching teams on the Apple Developer console. When a user uses Apple to sign in,
|
||||
LMS receives an `id_token from apple containing user information`_, including
|
||||
user's unique identifier with key `sub`. This unique identifier is unique to
|
||||
Apple team this user belongs to. Upon switching teams on Apple, developers need
|
||||
to migrate users from one team to another i.e. migrate users' unique
|
||||
identifiers. In the LMS, users' unique apple identifiers are stored in
|
||||
social_django.models.UserSocialAuth.uid. Following is an outline specifying the
|
||||
migration process.
|
||||
|
||||
1. `Create transfer_identifiers for all apple users`_ using the current respective apple unique id.
|
||||
|
||||
i. Run management command generate_and_store_apple_transfer_ids to generate and store apple transfer ids.
|
||||
|
||||
ii. Transfer ids are stored in third_party_auth.models.AppleMigrationUserIdInfo to be used later on.
|
||||
|
||||
2. Transfer/Migrate teams on Apple account.
|
||||
|
||||
i. After the migration, `Apple continues to send the transfer identifier`_ with key `transfer_sub` in information sent after login.
|
||||
|
||||
ii. These transfer identifiers are available in the login information for 60 days after team transfer.
|
||||
|
||||
ii. The method get_user_id() in third_party_auth.appleid.AppleIdAuth enables existing users to sign in by matching the transfer_sub sent in the login information with stored records of old Apple unique identifiers in third_party_auth.models.AppleMigrationUserIdInfo.
|
||||
|
||||
3. Update Apple Backend credentials in third_party_auth.models.OAuth2ProviderConfig for the Apple backend.
|
||||
|
||||
4. Create new team-scoped apple unique ids' for users after the migration using transfer ids created in Step 1.
|
||||
|
||||
i. Run management command generate_and_store_new_apple_ids to generate and store new team-scoped apple ids.
|
||||
|
||||
5. Update apple unique identifiers in the Database with new team-scoped apple ids retrieved in step 3.
|
||||
|
||||
i. Run management command update_new_apple_ids_in_social_auth.
|
||||
|
||||
6. Apple user migration is complete!
|
||||
|
||||
|
||||
.. _id_token from apple containing user information: https://developer.apple.com/documentation/sign_in_with_apple/sign_in_with_apple_rest_api/authenticating_users_with_sign_in_with_apple
|
||||
.. _Create transfer_identifiers for all apple users: https://developer.apple.com/documentation/sign_in_with_apple/transferring_your_apps_and_users_to_another_team
|
||||
.. _Apple continues to send the transfer identifier: https://developer.apple.com/documentation/sign_in_with_apple/sign_in_with_apple_rest_api/authenticating_users_with_sign_in_with_apple
|
||||
@@ -0,0 +1,118 @@
|
||||
"""
|
||||
Management command to generate Transfer Identifiers for users who signed in with Apple.
|
||||
These transfer identifiers are used in the event of migrating an app from one team to another.
|
||||
"""
|
||||
|
||||
|
||||
import logging
|
||||
import requests
|
||||
import time
|
||||
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
from django.db import transaction
|
||||
import jwt
|
||||
from social_django.models import UserSocialAuth
|
||||
from social_django.utils import load_strategy
|
||||
|
||||
|
||||
from common.djangoapps.third_party_auth.models import AppleMigrationUserIdInfo
|
||||
from common.djangoapps.third_party_auth.appleid import AppleIdAuth
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
"""
|
||||
Management command to generate transfer identifiers for apple users using their apple_id
|
||||
stored in social_django.models.UserSocialAuth.uid.
|
||||
|
||||
Usage:
|
||||
manage.py generate_and_store_apple_transfer_ids <target_team_id>
|
||||
"""
|
||||
|
||||
def _generate_client_secret(self):
|
||||
"""
|
||||
Generate client secret for use in Apple API's
|
||||
"""
|
||||
now = int(time.time())
|
||||
expiry = 60 * 60 * 3 # 3 hours
|
||||
|
||||
backend = load_strategy().get_backend(AppleIdAuth.name)
|
||||
team_id = backend.setting('TEAM')
|
||||
key_id = backend.setting('KEY')
|
||||
private_key = backend.get_private_key()
|
||||
audience = backend.TOKEN_AUDIENCE
|
||||
|
||||
headers = {
|
||||
"alg": "ES256",
|
||||
'kid': key_id
|
||||
}
|
||||
payload = {
|
||||
'iss': team_id,
|
||||
'iat': now,
|
||||
'exp': now + expiry,
|
||||
'aud': audience,
|
||||
'sub': "org.edx.mobile",
|
||||
}
|
||||
|
||||
return jwt.encode(payload, key=private_key, algorithm='ES256',
|
||||
headers=headers)
|
||||
|
||||
def _generate_access_token(self, client_secret):
|
||||
"""
|
||||
Generate access token for use in Apple API's
|
||||
"""
|
||||
access_token_url = 'https://appleid.apple.com/auth/token'
|
||||
app_id = "org.edx.mobile"
|
||||
payload = {
|
||||
"grant_type": "client_credentials",
|
||||
"scope": "user.migration",
|
||||
"client_id": app_id,
|
||||
"client_secret": client_secret
|
||||
}
|
||||
headers = {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
"Host": "appleid.apple.com"
|
||||
}
|
||||
response = requests.post(access_token_url, data=payload, headers=headers)
|
||||
access_token = response.json().get('access_token')
|
||||
return access_token
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument('target_team_id', help='Team ID to which the app is to be migrated to.')
|
||||
|
||||
@transaction.atomic
|
||||
def handle(self, *args, **options):
|
||||
target_team_id = options['target_team_id']
|
||||
|
||||
migration_url = "https://appleid.apple.com/auth/usermigrationinfo"
|
||||
app_id = "org.edx.mobile"
|
||||
|
||||
client_secret = self._generate_client_secret()
|
||||
access_token = self._generate_access_token(client_secret)
|
||||
if not access_token:
|
||||
raise CommandError('Failed to create access token.')
|
||||
|
||||
headers = {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
"Host": "appleid.apple.com",
|
||||
"Authorization": "Bearer " + access_token
|
||||
}
|
||||
payload = {
|
||||
"target": target_team_id,
|
||||
"client_id": app_id,
|
||||
"client_secret": client_secret
|
||||
}
|
||||
|
||||
apple_ids = UserSocialAuth.objects.filter(provider=AppleIdAuth.name).values_list('uid', flat=True)
|
||||
for apple_id in apple_ids:
|
||||
payload['sub'] = apple_id
|
||||
response = requests.post(migration_url, data=payload, headers=headers)
|
||||
transfer_id = response.json().get('transfer_sub')
|
||||
if transfer_id:
|
||||
apple_user_id_info, _ = AppleMigrationUserIdInfo.objects.get_or_create(old_apple_id=apple_id)
|
||||
apple_user_id_info.transfer_id = transfer_id
|
||||
apple_user_id_info.save()
|
||||
log.info('Updated transfer_id for uid %s', apple_id)
|
||||
else:
|
||||
log.info('Unable to fetch transfer_id for uid %s', apple_id)
|
||||
@@ -0,0 +1,111 @@
|
||||
"""
|
||||
Management command to exchange apple transfer identifiers with Apple ID of the
|
||||
user for new migrated team.
|
||||
"""
|
||||
|
||||
|
||||
import logging
|
||||
import requests
|
||||
import time
|
||||
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
from django.db import transaction
|
||||
import jwt
|
||||
from social_django.utils import load_strategy
|
||||
|
||||
from common.djangoapps.third_party_auth.models import AppleMigrationUserIdInfo
|
||||
from common.djangoapps.third_party_auth.appleid import AppleIdAuth
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
"""
|
||||
Management command to exchange transfer identifiers for new team-scoped identifier for
|
||||
the user in new migrated team.
|
||||
|
||||
Usage:
|
||||
manage.py generate_and_store_apple_transfer_ids
|
||||
"""
|
||||
|
||||
def _generate_client_secret(self):
|
||||
"""
|
||||
Generate client secret for use in Apple API's
|
||||
"""
|
||||
now = int(time.time())
|
||||
expiry = 60 * 60 * 3 # 3 hours
|
||||
|
||||
backend = load_strategy().get_backend(AppleIdAuth.name)
|
||||
team_id = backend.setting('TEAM')
|
||||
key_id = backend.setting('KEY')
|
||||
private_key = backend.get_private_key()
|
||||
audience = backend.TOKEN_AUDIENCE
|
||||
|
||||
headers = {
|
||||
"alg": "ES256",
|
||||
'kid': key_id
|
||||
}
|
||||
payload = {
|
||||
'iss': team_id,
|
||||
'iat': now,
|
||||
'exp': now + expiry,
|
||||
'aud': audience,
|
||||
'sub': "org.edx.mobile",
|
||||
}
|
||||
|
||||
return jwt.encode(payload, key=private_key, algorithm='ES256',
|
||||
headers=headers)
|
||||
|
||||
def _generate_access_token(self, client_secret):
|
||||
"""
|
||||
Generate access token for use in Apple API's
|
||||
"""
|
||||
access_token_url = 'https://appleid.apple.com/auth/token'
|
||||
app_id = "org.edx.mobile"
|
||||
payload = {
|
||||
"grant_type": "client_credentials",
|
||||
"scope": "user.migration",
|
||||
"client_id": app_id,
|
||||
"client_secret": client_secret
|
||||
}
|
||||
headers = {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
"Host": "appleid.apple.com"
|
||||
}
|
||||
response = requests.post(access_token_url, data=payload, headers=headers)
|
||||
access_token = response.json().get('access_token')
|
||||
return access_token
|
||||
|
||||
@transaction.atomic
|
||||
def handle(self, *args, **options):
|
||||
migration_url = "https://appleid.apple.com/auth/usermigrationinfo"
|
||||
app_id = "org.edx.mobile"
|
||||
|
||||
client_secret = self._generate_client_secret()
|
||||
access_token = self._generate_access_token(client_secret)
|
||||
if not access_token:
|
||||
raise CommandError('Failed to create access token.')
|
||||
|
||||
headers = {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
"Host": "appleid.apple.com",
|
||||
"Authorization": "Bearer " + access_token
|
||||
}
|
||||
payload = {
|
||||
"client_id": app_id,
|
||||
"client_secret": client_secret
|
||||
}
|
||||
|
||||
apple_user_ids_info = AppleMigrationUserIdInfo.objects.all()
|
||||
for apple_user_id_info in apple_user_ids_info:
|
||||
payload['transfer_sub'] = apple_user_id_info.transfer_id
|
||||
response = requests.post(migration_url, data=payload, headers=headers)
|
||||
new_apple_id = response.json().get('sub')
|
||||
if new_apple_id:
|
||||
apple_user_id_info.new_apple_id = new_apple_id
|
||||
apple_user_id_info.save()
|
||||
log.info('Updated new Apple ID for uid %s',
|
||||
apple_user_id_info.old_apple_id)
|
||||
else:
|
||||
log.info('Unable to fetch new Apple ID for uid %s',
|
||||
apple_user_id_info.old_apple_id)
|
||||
@@ -0,0 +1,85 @@
|
||||
"""
|
||||
Tests for `generate_and_store_apple_transfer_id` management command
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
from unittest import mock
|
||||
from django.core.management import call_command
|
||||
from django.core.management.base import CommandError
|
||||
from django.test import TestCase
|
||||
from requests.models import Response
|
||||
from social_django.models import UserSocialAuth
|
||||
|
||||
from common.djangoapps.student.tests.factories import UserFactory
|
||||
from common.djangoapps.third_party_auth.appleid import AppleIdAuth
|
||||
from common.djangoapps.third_party_auth.management.commands import generate_and_store_apple_transfer_ids
|
||||
from common.djangoapps.third_party_auth.models import AppleMigrationUserIdInfo
|
||||
from common.djangoapps.third_party_auth.tests.factories import OAuth2ProviderConfigFactory
|
||||
from openedx.core.djangolib.testing.utils import skip_unless_lms
|
||||
|
||||
|
||||
@skip_unless_lms
|
||||
class TestGenerateAndStoreTransferIds(TestCase):
|
||||
"""
|
||||
Test Django management command
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
cls.command = generate_and_store_apple_transfer_ids.Command()
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.slug = 'garfield'
|
||||
self.provider_garfield = OAuth2ProviderConfigFactory.create(slug='garfield')
|
||||
self.user = UserFactory(username='fleur')
|
||||
self.create_social_auth_entry(self.user, self.provider_garfield)
|
||||
|
||||
def create_social_auth_entry(self, user, provider):
|
||||
external_id = 'sample_old_apple_id'
|
||||
UserSocialAuth.objects.create(
|
||||
user=user,
|
||||
uid=f'{external_id}',
|
||||
provider=provider.slug,
|
||||
)
|
||||
|
||||
@mock.patch('common.djangoapps.third_party_auth.management.commands.'
|
||||
'generate_and_store_apple_transfer_ids.Command._generate_access_token')
|
||||
@mock.patch('common.djangoapps.third_party_auth.management.commands.'
|
||||
'generate_and_store_apple_transfer_ids.Command._generate_client_secret')
|
||||
def test_access_token_error(self, mock_generate_client_secret, mock_generate_access_token):
|
||||
mock_generate_client_secret.return_value = 'sample_client_secret'
|
||||
mock_generate_access_token.return_value = None
|
||||
|
||||
error_string = 'Failed to create access token.'
|
||||
with self.assertRaisesRegex(CommandError, error_string):
|
||||
call_command(self.command, 'sample_team_id')
|
||||
|
||||
@mock.patch('common.djangoapps.third_party_auth.management.commands.'
|
||||
'generate_and_store_apple_transfer_ids.Command._generate_access_token')
|
||||
@mock.patch('common.djangoapps.third_party_auth.management.commands.'
|
||||
'generate_and_store_apple_transfer_ids.Command._generate_client_secret')
|
||||
@mock.patch('requests.post')
|
||||
def test_transfer_id_created(self, mock_post, mock_generate_client_secret, mock_generate_access_token):
|
||||
response = Response()
|
||||
response.status_code = 200
|
||||
response_content = {'transfer_sub': 'sample_transfer_sub'}
|
||||
response._content = json.dumps(response_content).encode('utf-8') # pylint: disable=protected-access
|
||||
|
||||
mock_post.return_value = response
|
||||
mock_generate_client_secret.return_value = 'sample_client_secret'
|
||||
mock_generate_access_token.return_value = 'sample_access_token'
|
||||
|
||||
self.assertEqual(0, AppleMigrationUserIdInfo.objects.all().count())
|
||||
|
||||
with mock.patch.object(AppleIdAuth, 'name', self.slug):
|
||||
call_command(self.command, 'sample_team_id')
|
||||
|
||||
self.assertTrue(AppleMigrationUserIdInfo.objects.filter(
|
||||
old_apple_id='sample_old_apple_id').exists())
|
||||
expected_transfer_sub = 'sample_transfer_sub'
|
||||
actual_transfer_sub = AppleMigrationUserIdInfo.objects.filter(
|
||||
old_apple_id='sample_old_apple_id').first().transfer_id
|
||||
self.assertEqual(expected_transfer_sub, actual_transfer_sub)
|
||||
@@ -0,0 +1,90 @@
|
||||
"""
|
||||
Tests for `generate_and_store_new_apple_ids` management command
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
from unittest import mock
|
||||
from django.core.management import call_command
|
||||
from django.core.management.base import CommandError
|
||||
from django.test import TestCase
|
||||
from requests.models import Response
|
||||
from social_django.models import UserSocialAuth
|
||||
|
||||
from common.djangoapps.student.tests.factories import UserFactory
|
||||
from common.djangoapps.third_party_auth.appleid import AppleIdAuth
|
||||
from common.djangoapps.third_party_auth.management.commands import generate_and_store_new_apple_ids
|
||||
from common.djangoapps.third_party_auth.models import AppleMigrationUserIdInfo
|
||||
from common.djangoapps.third_party_auth.tests.factories import OAuth2ProviderConfigFactory
|
||||
from openedx.core.djangolib.testing.utils import skip_unless_lms
|
||||
|
||||
|
||||
@skip_unless_lms
|
||||
class TestGenerateAndStoreAppleIds(TestCase):
|
||||
"""
|
||||
Test Django management command
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
cls.command = generate_and_store_new_apple_ids.Command()
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.slug = 'garfield'
|
||||
self.provider_garfield = OAuth2ProviderConfigFactory.create(slug='garfield')
|
||||
self.user = UserFactory(username='fleur')
|
||||
self.create_social_auth_entry(self.user, self.provider_garfield)
|
||||
self.create_apple_migration_user_info_entry()
|
||||
|
||||
def create_social_auth_entry(self, user, provider):
|
||||
external_id = 'sample_old_apple_id'
|
||||
UserSocialAuth.objects.create(
|
||||
user=user,
|
||||
uid=f'{external_id}',
|
||||
provider=provider.slug,
|
||||
)
|
||||
|
||||
def create_apple_migration_user_info_entry(self):
|
||||
AppleMigrationUserIdInfo.objects.create(
|
||||
old_apple_id='sample_old_apple_id',
|
||||
transfer_id='sample_transfer_sub'
|
||||
)
|
||||
|
||||
@mock.patch('common.djangoapps.third_party_auth.management.commands.'
|
||||
'generate_and_store_new_apple_ids.Command._generate_access_token')
|
||||
@mock.patch('common.djangoapps.third_party_auth.management.commands.'
|
||||
'generate_and_store_new_apple_ids.Command._generate_client_secret')
|
||||
def test_access_token_error(self, mock_generate_client_secret, mock_generate_access_token):
|
||||
mock_generate_client_secret.return_value = 'sample_client_secret'
|
||||
mock_generate_access_token.return_value = None
|
||||
|
||||
error_string = 'Failed to create access token.'
|
||||
with self.assertRaisesRegex(CommandError, error_string):
|
||||
call_command(self.command)
|
||||
|
||||
@mock.patch('common.djangoapps.third_party_auth.management.commands.'
|
||||
'generate_and_store_new_apple_ids.Command._generate_access_token')
|
||||
@mock.patch('common.djangoapps.third_party_auth.management.commands.'
|
||||
'generate_and_store_new_apple_ids.Command._generate_client_secret')
|
||||
@mock.patch('requests.post')
|
||||
def test_new_apple_id_created(self, mock_post, mock_generate_client_secret, mock_generate_access_token):
|
||||
response = Response()
|
||||
response.status_code = 200
|
||||
response_content = {'sub': 'sample_new_apple_id'}
|
||||
response._content = json.dumps(response_content).encode('utf-8') # pylint: disable=protected-access
|
||||
|
||||
mock_post.return_value = response
|
||||
mock_generate_client_secret.return_value = 'sample_client_secret'
|
||||
mock_generate_access_token.return_value = 'sample_access_token'
|
||||
|
||||
with mock.patch.object(AppleIdAuth, 'name', self.slug):
|
||||
call_command(self.command)
|
||||
|
||||
self.assertTrue(AppleMigrationUserIdInfo.objects.filter(
|
||||
transfer_id='sample_transfer_sub').exists())
|
||||
expected_new_apple_id = 'sample_new_apple_id'
|
||||
actual_new_apple_id = AppleMigrationUserIdInfo.objects.get(
|
||||
transfer_id='sample_transfer_sub').new_apple_id
|
||||
self.assertEqual(expected_new_apple_id, actual_new_apple_id)
|
||||
@@ -0,0 +1,60 @@
|
||||
"""
|
||||
Tests for `update_new_apple_ids_in_social_auth` management command
|
||||
"""
|
||||
|
||||
from unittest import mock
|
||||
from django.core.management import call_command
|
||||
from django.test import TestCase
|
||||
from social_django.models import UserSocialAuth
|
||||
|
||||
from common.djangoapps.student.tests.factories import UserFactory
|
||||
from common.djangoapps.third_party_auth.appleid import AppleIdAuth
|
||||
from common.djangoapps.third_party_auth.management.commands import update_new_apple_ids_in_social_auth
|
||||
from common.djangoapps.third_party_auth.models import AppleMigrationUserIdInfo
|
||||
from common.djangoapps.third_party_auth.tests.factories import OAuth2ProviderConfigFactory
|
||||
from openedx.core.djangolib.testing.utils import skip_unless_lms
|
||||
|
||||
|
||||
@skip_unless_lms
|
||||
class TestGenerateAndStoreAppleIds(TestCase):
|
||||
"""
|
||||
Test Django management command
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
cls.command = update_new_apple_ids_in_social_auth.Command()
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.slug = 'garfield'
|
||||
self.provider_garfield = OAuth2ProviderConfigFactory.create(slug='garfield')
|
||||
self.user = UserFactory(username='fleur')
|
||||
self.create_social_auth_entry(self.user, self.provider_garfield)
|
||||
self.create_apple_migration_user_info_entry()
|
||||
|
||||
def create_social_auth_entry(self, user, provider):
|
||||
external_id = 'sample_old_apple_id'
|
||||
UserSocialAuth.objects.create(
|
||||
user=user,
|
||||
uid=f'{external_id}',
|
||||
provider=provider.slug,
|
||||
)
|
||||
|
||||
def create_apple_migration_user_info_entry(self):
|
||||
AppleMigrationUserIdInfo.objects.create(
|
||||
old_apple_id='sample_old_apple_id',
|
||||
transfer_id='sample_transfer_sub',
|
||||
new_apple_id='sample_new_apple_id'
|
||||
)
|
||||
|
||||
def test_new_apple_id_updated_in_social_auth(self):
|
||||
self.assertTrue(UserSocialAuth.objects.filter(uid='sample_old_apple_id', provider=self.slug).exists())
|
||||
self.assertFalse(UserSocialAuth.objects.filter(uid='sample_new_apple_id', provider=self.slug).exists())
|
||||
|
||||
with mock.patch.object(AppleIdAuth, 'name', self.slug):
|
||||
call_command(self.command)
|
||||
|
||||
self.assertTrue(UserSocialAuth.objects.filter(uid='sample_new_apple_id', provider=self.slug).exists())
|
||||
self.assertFalse(UserSocialAuth.objects.filter(uid='sample_old_apple_id', provider=self.slug).exists())
|
||||
@@ -0,0 +1,44 @@
|
||||
"""
|
||||
Management command to update new Apple ID from AppleMigrationUserIdInfo to UserSocialAuth.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
from django.db import transaction
|
||||
from django.db.models import Q
|
||||
from social_django.models import UserSocialAuth
|
||||
|
||||
from common.djangoapps.third_party_auth.models import AppleMigrationUserIdInfo
|
||||
from common.djangoapps.third_party_auth.appleid import AppleIdAuth
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
"""
|
||||
Management command to update new Apple ID from AppleMigrationUserIdInfo to UserSocialAuth.
|
||||
|
||||
Usage:
|
||||
manage.py update_new_apple_ids_in_social_auth
|
||||
"""
|
||||
|
||||
@transaction.atomic
|
||||
def handle(self, *args, **options):
|
||||
apple_user_ids_info = AppleMigrationUserIdInfo.objects.filter(
|
||||
~Q(new_apple_id=''), new_apple_id__isnull=False
|
||||
)
|
||||
if not apple_user_ids_info:
|
||||
raise CommandError('No Apple ID User info found.')
|
||||
for apple_user_id_info in apple_user_ids_info:
|
||||
user_social_auth = UserSocialAuth.objects.filter(
|
||||
uid=apple_user_id_info.old_apple_id, provider=AppleIdAuth.name
|
||||
).first()
|
||||
if user_social_auth:
|
||||
user_social_auth.uid = apple_user_id_info.new_apple_id
|
||||
user_social_auth.save()
|
||||
log.info(
|
||||
'Replaced Apple ID %s with %s',
|
||||
apple_user_id_info.old_apple_id,
|
||||
apple_user_id_info.new_apple_id
|
||||
)
|
||||
@@ -0,0 +1,26 @@
|
||||
# Generated by Django 3.2.18 on 2023-02-28 11:51
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('third_party_auth', '0010_delete_historicalusersocialauth'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='AppleMigrationUserIdInfo',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('old_apple_id', models.CharField(max_length=255)),
|
||||
('transfer_id', models.CharField(blank=True, max_length=255, null=True)),
|
||||
('new_apple_id', models.CharField(blank=True, max_length=255, null=True)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Apple User Id Migration Info',
|
||||
'verbose_name_plural': 'Apple User Id Migration Info',
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -992,3 +992,21 @@ class LTIProviderConfig(ProviderConfig):
|
||||
app_label = "third_party_auth"
|
||||
verbose_name = "Provider Configuration (LTI)"
|
||||
verbose_name_plural = verbose_name
|
||||
|
||||
|
||||
class AppleMigrationUserIdInfo(models.Model):
|
||||
"""
|
||||
Model to store users' Apple Unique Identifier during migration
|
||||
process of Apple team from edx Inc. to edx LLC.
|
||||
"""
|
||||
old_apple_id = models.CharField(max_length=255)
|
||||
transfer_id = models.CharField(max_length=255, null=True, blank=True)
|
||||
new_apple_id = models.CharField(max_length=255, null=True, blank=True)
|
||||
|
||||
def __str__(self):
|
||||
return self.old_apple_id
|
||||
|
||||
class Meta:
|
||||
app_label = "third_party_auth"
|
||||
verbose_name = "Apple User Id Migration Info"
|
||||
verbose_name_plural = verbose_name
|
||||
|
||||
@@ -7,7 +7,9 @@ from factory import SubFactory
|
||||
from factory.django import DjangoModelFactory
|
||||
from faker import Factory as FakerFactory
|
||||
|
||||
from common.djangoapps.third_party_auth.models import SAMLConfiguration, SAMLProviderConfig
|
||||
from common.djangoapps.third_party_auth.models import (
|
||||
OAuth2ProviderConfig, SAMLConfiguration, SAMLProviderConfig
|
||||
)
|
||||
from openedx.core.djangoapps.site_configuration.tests.factories import SiteFactory
|
||||
|
||||
FAKER = FakerFactory.create()
|
||||
@@ -40,3 +42,16 @@ class SAMLProviderConfigFactory(DjangoModelFactory):
|
||||
|
||||
entity_id = factory.LazyAttribute(lambda x: FAKER.uri())
|
||||
metadata_source = factory.LazyAttribute(lambda x: FAKER.uri())
|
||||
|
||||
|
||||
class OAuth2ProviderConfigFactory(DjangoModelFactory):
|
||||
"""
|
||||
Factory for OAuth2ProviderConfig model in third_party_auth app.
|
||||
"""
|
||||
class Meta:
|
||||
model = OAuth2ProviderConfig
|
||||
|
||||
site = SubFactory(SiteFactory)
|
||||
enabled = True
|
||||
slug = factory.LazyAttribute(lambda x: FAKER.slug())
|
||||
name = factory.LazyAttribute(lambda x: FAKER.company())
|
||||
|
||||
25
common/djangoapps/third_party_auth/toggles.py
Normal file
25
common/djangoapps/third_party_auth/toggles.py
Normal file
@@ -0,0 +1,25 @@
|
||||
"""
|
||||
Togglable settings for Third Party Auth
|
||||
"""
|
||||
|
||||
from edx_toggles.toggles import WaffleFlag
|
||||
|
||||
THIRD_PARTY_AUTH_NAMESPACE = 'thirdpartyauth'
|
||||
|
||||
# .. toggle_name: third_party_auth.apple_user_migration
|
||||
# .. toggle_implementation: WaffleFlag
|
||||
# .. toggle_default: False
|
||||
# .. toggle_description: Enable User ID matching while apple migration is in process
|
||||
# .. toggle_use_cases: temporary
|
||||
# .. toggle_creation_date: 2023-02-27
|
||||
# .. toggle_target_removal_date: 2023-05-01
|
||||
# .. toggle_tickets: LEARNER-8790
|
||||
# .. toggle_warning: None.
|
||||
APPLE_USER_MIGRATION_FLAG = WaffleFlag(f'{THIRD_PARTY_AUTH_NAMESPACE}.apple_user_migration', __name__)
|
||||
|
||||
|
||||
def is_apple_user_migration_enabled():
|
||||
"""
|
||||
Returns a boolean if Apple users migration is in process.
|
||||
"""
|
||||
return APPLE_USER_MIGRATION_FLAG.is_enabled()
|
||||
Reference in New Issue
Block a user