diff --git a/common/djangoapps/third_party_auth/admin.py b/common/djangoapps/third_party_auth/admin.py index 6b0ca785fd82..972af1622001 100644 --- a/common/djangoapps/third_party_auth/admin.py +++ b/common/djangoapps/third_party_auth/admin.py @@ -14,6 +14,7 @@ from .models import ( _PSA_OAUTH2_BACKENDS, _PSA_SAML_BACKENDS, + AppleMigrationUserIdInfo, LTIProviderConfig, OAuth2ProviderConfig, SAMLConfiguration, @@ -196,3 +197,10 @@ def get_list_display(self, request): ) admin.site.register(LTIProviderConfig, LTIProviderConfigAdmin) + + +class AppleMigrationUserIdInfoAdmin(admin.ModelAdmin): + """ Django Admin class for AppleMigrationUserIdInfo """ + + +admin.site.register(AppleMigrationUserIdInfo, AppleMigrationUserIdInfoAdmin) diff --git a/common/djangoapps/third_party_auth/appleid.py b/common/djangoapps/third_party_auth/appleid.py index ef5e70eadeb4..b6472e2c1bd1 100644 --- a/common/djangoapps/third_party_auth/appleid.py +++ b/common/djangoapps/third_party_auth/appleid.py @@ -78,8 +78,12 @@ 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 @@ def get_user_details(self, response): 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 diff --git a/common/djangoapps/third_party_auth/docs/how_tos/migrating_apple_users_in_teams.rst b/common/djangoapps/third_party_auth/docs/how_tos/migrating_apple_users_in_teams.rst new file mode 100644 index 000000000000..6cc6783a8d2b --- /dev/null +++ b/common/djangoapps/third_party_auth/docs/how_tos/migrating_apple_users_in_teams.rst @@ -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 diff --git a/common/djangoapps/third_party_auth/management/commands/generate_and_store_apple_transfer_ids.py b/common/djangoapps/third_party_auth/management/commands/generate_and_store_apple_transfer_ids.py new file mode 100644 index 000000000000..f7856d3cfc0b --- /dev/null +++ b/common/djangoapps/third_party_auth/management/commands/generate_and_store_apple_transfer_ids.py @@ -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 + """ + + 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) diff --git a/common/djangoapps/third_party_auth/management/commands/generate_and_store_new_apple_ids.py b/common/djangoapps/third_party_auth/management/commands/generate_and_store_new_apple_ids.py new file mode 100644 index 000000000000..5f9c69f779bc --- /dev/null +++ b/common/djangoapps/third_party_auth/management/commands/generate_and_store_new_apple_ids.py @@ -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) diff --git a/common/djangoapps/third_party_auth/management/commands/tests/test_generate_and_store_apple_transfer_ids.py b/common/djangoapps/third_party_auth/management/commands/tests/test_generate_and_store_apple_transfer_ids.py new file mode 100644 index 000000000000..e80b8069c913 --- /dev/null +++ b/common/djangoapps/third_party_auth/management/commands/tests/test_generate_and_store_apple_transfer_ids.py @@ -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) diff --git a/common/djangoapps/third_party_auth/management/commands/tests/test_generate_and_store_new_apple_ids.py b/common/djangoapps/third_party_auth/management/commands/tests/test_generate_and_store_new_apple_ids.py new file mode 100644 index 000000000000..2d79187215b2 --- /dev/null +++ b/common/djangoapps/third_party_auth/management/commands/tests/test_generate_and_store_new_apple_ids.py @@ -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) diff --git a/common/djangoapps/third_party_auth/management/commands/tests/test_update_new_apple_ids_in_social_auth.py b/common/djangoapps/third_party_auth/management/commands/tests/test_update_new_apple_ids_in_social_auth.py new file mode 100644 index 000000000000..be8f31736dcd --- /dev/null +++ b/common/djangoapps/third_party_auth/management/commands/tests/test_update_new_apple_ids_in_social_auth.py @@ -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()) diff --git a/common/djangoapps/third_party_auth/management/commands/update_new_apple_ids_in_social_auth.py b/common/djangoapps/third_party_auth/management/commands/update_new_apple_ids_in_social_auth.py new file mode 100644 index 000000000000..f3314efb1a36 --- /dev/null +++ b/common/djangoapps/third_party_auth/management/commands/update_new_apple_ids_in_social_auth.py @@ -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 + ) diff --git a/common/djangoapps/third_party_auth/migrations/0011_applemigrationuseridinfo.py b/common/djangoapps/third_party_auth/migrations/0011_applemigrationuseridinfo.py new file mode 100644 index 000000000000..57bc79ed8a46 --- /dev/null +++ b/common/djangoapps/third_party_auth/migrations/0011_applemigrationuseridinfo.py @@ -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', + }, + ), + ] diff --git a/common/djangoapps/third_party_auth/models.py b/common/djangoapps/third_party_auth/models.py index 6c66804ccea6..af5159764c92 100644 --- a/common/djangoapps/third_party_auth/models.py +++ b/common/djangoapps/third_party_auth/models.py @@ -992,3 +992,21 @@ class Meta: 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 diff --git a/common/djangoapps/third_party_auth/tests/factories.py b/common/djangoapps/third_party_auth/tests/factories.py index f10012472895..b47da33d81e2 100644 --- a/common/djangoapps/third_party_auth/tests/factories.py +++ b/common/djangoapps/third_party_auth/tests/factories.py @@ -7,7 +7,9 @@ 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 Meta: 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()) diff --git a/common/djangoapps/third_party_auth/toggles.py b/common/djangoapps/third_party_auth/toggles.py new file mode 100644 index 000000000000..53c4edd295ed --- /dev/null +++ b/common/djangoapps/third_party_auth/toggles.py @@ -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()