From 9ca25b9b205f279a889bf737845527ff1706f0ae Mon Sep 17 00:00:00 2001 From: Brian Rodriguez Date: Fri, 2 Apr 2021 19:44:01 -0400 Subject: [PATCH] Fix #11462: Migrate to Firebase authentication (#12392) * bump pip to 20.3.4 * add 20.3.4 check * add 20.3.4 tests * improve error message * fix lint * auto-upgrade pip * Migrate to Firebase authentication * address comments * create user in lighthouse main * add comments to FirebaseErrorFilterHandler * undo disable account deletion * address comments * fix lint * suppress user info when on login/logout pages * always use email.toLowerCase() * combine AdminSuperAdminPrivilegesHandler --- .firebase.json | 7 + .github/CODEOWNERS | 3 + .isort.cfg | 2 +- app_dev.yaml | 26 + assets/constants.ts | 13 +- core/controllers/admin.py | 46 + core/controllers/admin_test.py | 114 ++ core/controllers/base.py | 36 +- core/controllers/base_test.py | 70 +- core/controllers/creator_dashboard_test.py | 2 +- core/controllers/editor_test.py | 47 - core/domain/auth_jobs_one_off.py | 355 ++++-- core/domain/auth_jobs_one_off_test.py | 598 +++++---- core/domain/auth_services.py | 26 + core/domain/auth_validators.py | 52 + core/domain/auth_validators_test.py | 23 + core/domain/prod_validation_jobs_one_off.py | 8 + core/domain/user_services.py | 11 +- core/jobs_registry.py | 3 +- core/platform/auth/firebase_auth_services.py | 165 ++- .../auth/firebase_auth_services_test.py | 1079 +++++++++++------ core/platform/models.py | 8 +- core/platform/models_test.py | 4 +- core/storage/auth/gae_models.py | 21 + core/storage/auth/gae_models_test.py | 21 + .../top-navigation-bar.directive.html | 2 +- .../admin/admin-backend-api.service.spec.ts | 28 + .../domain/admin/admin-backend-api.service.ts | 14 + .../pages/admin-page/admin-page.constants.ts | 1 + .../misc-tab/admin-misc-tab.directive.html | 30 + .../misc-tab/admin-misc-tab.directive.ts | 38 + core/templates/pages/header_css_libs.html | 2 - .../login-page/login-page.component.html | 78 ++ .../login-page/login-page.component.spec.ts | 314 +++++ .../pages/login-page/login-page.component.ts | 108 ++ .../pages/login-page/login-page.import.ts | 36 + .../pages/login-page/login-page.mainpage.html | 26 + .../pages/login-page/login-page.module.ts | 118 ++ .../logout-page/logout-page.component.spec.ts | 163 +++ .../logout-page/logout-page.component.ts | 58 + .../pages/logout-page/logout-page.import.ts | 36 + .../logout-page/logout-page.mainpage.html | 26 + .../pages/logout-page/logout-page.module.ts | 71 ++ core/templates/services/alerts.service.ts | 63 +- .../services/auth-backend-api.service.spec.ts | 14 +- .../services/auth-backend-api.service.ts | 4 + core/templates/services/auth.service.spec.ts | 211 ++-- core/templates/services/auth.service.ts | 160 ++- core/templates/services/loader.service.ts | 14 +- core/templates/services/user.service.spec.ts | 20 + core/templates/services/user.service.ts | 19 +- ....txt => requirements_invalid_git_test.txt} | 5 +- core/tests/protractor.conf.js | 8 + core/tests/protractor/featureGatingFlow.js | 25 - .../protractor_desktop/topicAndStoryViewer.js | 11 +- core/tests/protractor_desktop/wipeout.js | 21 +- core/tests/protractor_mobile/navigation.js | 2 +- .../TopicAndStoryViewerPage.js | 7 - core/tests/protractor_utils/general.js | 56 +- core/tests/protractor_utils/users.js | 159 ++- core/tests/puppeteer/lighthouse_setup.js | 11 +- core/tests/test_utils.py | 42 +- core/tests/test_utils_test.py | 83 +- feconf.py | 7 +- main.py | 9 +- package.json | 3 +- puppeteer-login-script.js | 11 +- scripts/build.py | 13 +- scripts/build_test.py | 1 + scripts/common.py | 1 + scripts/pre_commit_hook.py | 5 +- webpack.common.config.ts | 18 + yarn.lock | 380 ++++-- 73 files changed, 3963 insertions(+), 1309 deletions(-) create mode 100644 .firebase.json create mode 100644 core/templates/pages/login-page/login-page.component.html create mode 100644 core/templates/pages/login-page/login-page.component.spec.ts create mode 100644 core/templates/pages/login-page/login-page.component.ts create mode 100644 core/templates/pages/login-page/login-page.import.ts create mode 100644 core/templates/pages/login-page/login-page.mainpage.html create mode 100644 core/templates/pages/login-page/login-page.module.ts create mode 100644 core/templates/pages/logout-page/logout-page.component.spec.ts create mode 100644 core/templates/pages/logout-page/logout-page.component.ts create mode 100644 core/templates/pages/logout-page/logout-page.import.ts create mode 100644 core/templates/pages/logout-page/logout-page.mainpage.html create mode 100644 core/templates/pages/logout-page/logout-page.module.ts rename core/tests/data/third_party/{ignorable_requirements_test.txt => requirements_invalid_git_test.txt} (75%) diff --git a/.firebase.json b/.firebase.json new file mode 100644 index 000000000000..9140b51247bc --- /dev/null +++ b/.firebase.json @@ -0,0 +1,7 @@ +{ + "emulators": { + "auth": { + "port": "9099" + } + } +} diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index b2c0809cef9a..df15ea866f0e 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -468,6 +468,8 @@ /core/templates/combined-tests.spec.ts @srijanreddy98 /core/templates/pages/interaction-specs.constants.ajs.ts @jameesjohn @vojtechjelinek /core/templates/pages/interaction-specs.constants.ts @nithusha21 +/core/templates/pages/login-page/ @brianrodri +/core/templates/pages/logout-page/ @brianrodri /core/templates/I18nFooter.ts @nithusha21 /core/templates/i18n-footer.directive.html @nithusha21 /core/templates/services/app.service*.ts @srijanreddy98 @@ -479,6 +481,7 @@ # TODO(#11811): Replace @BenHenning with @seanlip after 2021-02-07. /redis.conf @BenHenning /release_constants.json @DubeySandeep @nithusha21 +/.firebase.json @brianrodri # Miscellaneous. diff --git a/.isort.cfg b/.isort.cfg index 7cf66a92a171..c09433cb65ff 100644 --- a/.isort.cfg +++ b/.isort.cfg @@ -2,6 +2,6 @@ force_single_line=true force_sort_within_sections=true ignore_whitespace=true -known_third_party=apache_beam,backports.functools_lru_cache,browsermobproxy,cloudstorage,contextlib2,elasticsearch,firebase_admin,google.api_core,google.appengine,google.cloud,google.protobuf,jinja2,mapreduce,mutagen,pipeline,pkg_resources,psutil,pylatexenc,pylint,requests,requests_mock,selenium,six,skulpt,webapp2,webapp2_extras,webtest,yaml +known_third_party=apache_beam,backports.functools_lru_cache,browsermobproxy,cloudstorage,contextlib2,elasticsearch,firebase_admin,google.api_core,google.appengine,google.cloud,google.protobuf,jinja2,mapreduce,mock,mutagen,pipeline,pkg_resources,psutil,pylatexenc,pylint,requests,requests_mock,selenium,six,skulpt,webapp2,webapp2_extras,webtest,yaml line_length=80 sections=FUTURE,STDLIB,FIRSTPARTY,THIRDPARTY,LOCALFOLDER diff --git a/app_dev.yaml b/app_dev.yaml index f436d6470cf5..82d2de281659 100644 --- a/app_dev.yaml +++ b/app_dev.yaml @@ -186,6 +186,28 @@ handlers: X-Xss-Protection: "1; mode=block" secure: always expiration: "0" +- url: /login + static_files: webpack_bundles/login-page.mainpage.html + upload: webpack_bundles/login-page.mainpage.html + http_headers: + Pragma: no-cache + Strict-Transport-Security: "max-age=31536000; includeSubDomains" + X-Content-Type-Options: "nosniff" + X-Frame-Options: "DENY" + X-Xss-Protection: "1; mode=block" + secure: always + expiration: "0" +- url: /logout + static_files: webpack_bundles/logout-page.mainpage.html + upload: webpack_bundles/logout-page.mainpage.html + http_headers: + Pragma: no-cache + Strict-Transport-Security: "max-age=31536000; includeSubDomains" + X-Content-Type-Options: "nosniff" + X-Frame-Options: "DENY" + X-Xss-Protection: "1; mode=block" + secure: always + expiration: "0" - url: /creator-guidelines static_files: webpack_bundles/playbook.mainpage.html upload: webpack_bundles/playbook.mainpage.html @@ -273,6 +295,10 @@ env_variables: # redirects to these instructions to disable URL fetch: # https://cloud.google.com/appengine/docs/standard/python/sockets#making_httplib_use_sockets GAE_USE_SOCKETS_HTTPLIB : "anyvalue" +# FIREBASE_AUTH_EMULATOR_HOST is needed to allow the Firebase SDK to connect +# with the Firebase emulator. THIS MUST NOT BE DEPLOYED TO PRODUCTION. We +# protect against this in the build script. + FIREBASE_AUTH_EMULATOR_HOST: "localhost:9099" skip_files: # .pyc and .pyo files diff --git a/assets/constants.ts b/assets/constants.ts index 366b003c5f4c..36cf3c14b685 100644 --- a/assets/constants.ts +++ b/assets/constants.ts @@ -5439,21 +5439,22 @@ export default { "ANALYTICS_ID": "", "SITE_NAME_FOR_ANALYTICS": "", - "FIREBASE_AUTH_ENABLED": false, + "FIREBASE_AUTH_ENABLED": true, + + // TODO(#11462): Delete this after Firebase authentication has been deployed. + "ENABLE_LOGIN_PAGE": true, // Data required for Firebase authentication. // // NOTE TO RELEASE COORDINATORS: Please change these to the production values, // and change useEmulator to be false, before deploying to production. "FIREBASE_CONFIG_API_KEY": "fake-api-key", - "FIREBASE_CONFIG_APP_ID": "", "FIREBASE_CONFIG_AUTH_DOMAIN": "", - "FIREBASE_CONFIG_DATABASE_URL": "", - "FIREBASE_CONFIG_GOOGLE_CLIENT_ID": "", - "FIREBASE_CONFIG_MESSAGING_SENDER_ID": "", "FIREBASE_CONFIG_PROJECT_ID": "dev-project-id", "FIREBASE_CONFIG_STORAGE_BUCKET": "", - "FIREBASE_EMULATOR_ENABLED": true, + "FIREBASE_CONFIG_MESSAGING_SENDER_ID": "", + "FIREBASE_CONFIG_APP_ID": "", + "FIREBASE_CONFIG_GOOGLE_CLIENT_ID": "", "ALLOW_YAML_FILE_UPLOAD": false, diff --git a/core/controllers/admin.py b/core/controllers/admin.py index 725d4f195900..7f9063ca6421 100644 --- a/core/controllers/admin.py +++ b/core/controllers/admin.py @@ -25,6 +25,7 @@ from core import jobs_registry from core.controllers import acl_decorators from core.controllers import base +from core.domain import auth_services from core.domain import caching_services from core.domain import collection_services from core.domain import config_domain @@ -729,6 +730,51 @@ def post(self): self.render_json({}) +class AdminSuperAdminPrivilegesHandler(base.BaseHandler): + """Handler for granting a user super admin privileges.""" + + PUT_HANDLER_ERROR_RETURN_TYPE = feconf.HANDLER_TYPE_JSON + DELETE_HANDLER_ERROR_RETURN_TYPE = feconf.HANDLER_TYPE_JSON + + @acl_decorators.can_access_admin_page + def put(self): + if self.email != feconf.ADMIN_EMAIL_ADDRESS: + raise self.UnauthorizedUserException( + 'Only the default system admin can manage super admins') + + username = self.payload.get('username', None) + if username is None: + raise self.InvalidInputException('Missing username param') + + user_id = user_services.get_user_id_from_username(username) + if user_id is None: + raise self.InvalidInputException('No such user exists') + + auth_services.grant_super_admin_privileges(user_id) + self.render_json(self.values) + + @acl_decorators.can_access_admin_page + def delete(self): + if self.email != feconf.ADMIN_EMAIL_ADDRESS: + raise self.UnauthorizedUserException( + 'Only the default system admin can manage super admins') + + username = self.request.get('username', None) + if username is None: + raise self.InvalidInputException('Missing username param') + + user_settings = user_services.get_user_settings_from_username(username) + if user_settings is None: + raise self.InvalidInputException('No such user exists') + + if user_settings.email == feconf.ADMIN_EMAIL_ADDRESS: + raise self.InvalidInputException( + 'Cannot revoke privileges from the default super admin account') + + auth_services.revoke_super_admin_privileges(user_settings.user_id) + self.render_json(self.values) + + class AdminJobOutputHandler(base.BaseHandler): """Retrieves job output to show on the admin page.""" diff --git a/core/controllers/admin_test.py b/core/controllers/admin_test.py index da1c0772c123..72313d6139c5 100644 --- a/core/controllers/admin_test.py +++ b/core/controllers/admin_test.py @@ -48,6 +48,7 @@ from core.domain import user_services from core.domain import wipeout_service from core.platform import models +from core.platform.auth import firebase_auth_services from core.tests import test_utils import feconf import utils @@ -86,6 +87,7 @@ class AdminIntegrationTest(test_utils.GenericTestBase): def setUp(self): """Complete the signup process for self.ADMIN_EMAIL.""" super(AdminIntegrationTest, self).setUp() + self.signup(feconf.ADMIN_EMAIL_ADDRESS, 'testsuper') self.signup(self.ADMIN_EMAIL, self.ADMIN_USERNAME) self.signup(self.EDITOR_EMAIL, self.EDITOR_USERNAME) self.admin_id = self.get_user_id_from_email(self.ADMIN_EMAIL) @@ -1033,6 +1035,118 @@ def test_update_flag_rules_with_unexpected_exception_returns_500(self): feature.name) self.logout() + def test_grant_super_admin_privileges(self): + self.login(feconf.ADMIN_EMAIL_ADDRESS, is_super_admin=True) + + grant_super_admin_privileges_stub = self.swap_with_call_counter( + firebase_auth_services, 'grant_super_admin_privileges') + + with grant_super_admin_privileges_stub as call_counter: + response = self.put_json( + '/adminsuperadminhandler', + {'username': self.ADMIN_USERNAME}, + csrf_token=self.get_new_csrf_token(), + expected_status_int=200) + + self.assertEqual(call_counter.times_called, 1) + self.assertNotIn('error', response) + + def test_grant_super_admin_privileges_requires_system_default_admin(self): + self.login(self.ADMIN_EMAIL, is_super_admin=True) + + grant_super_admin_privileges_stub = self.swap_with_call_counter( + firebase_auth_services, 'grant_super_admin_privileges') + + with grant_super_admin_privileges_stub as call_counter: + response = self.put_json( + '/adminsuperadminhandler', + {'username': self.ADMIN_USERNAME}, + csrf_token=self.get_new_csrf_token(), + expected_status_int=401) + + self.assertEqual(call_counter.times_called, 0) + self.assertEqual( + response['error'], + 'Only the default system admin can manage super admins') + + def test_grant_super_admin_privileges_fails_without_username(self): + self.login(feconf.ADMIN_EMAIL_ADDRESS, is_super_admin=True) + + response = self.put_json( + '/adminsuperadminhandler', {}, csrf_token=self.get_new_csrf_token(), + expected_status_int=400) + + self.assertEqual(response['error'], 'Missing username param') + + def test_grant_super_admin_privileges_fails_with_invalid_username(self): + self.login(feconf.ADMIN_EMAIL_ADDRESS, is_super_admin=True) + + response = self.put_json( + '/adminsuperadminhandler', {'username': 'fakeusername'}, + csrf_token=self.get_new_csrf_token(), expected_status_int=400) + + self.assertEqual(response['error'], 'No such user exists') + + def test_revoke_super_admin_privileges(self): + self.login(feconf.ADMIN_EMAIL_ADDRESS, is_super_admin=True) + + revoke_super_admin_privileges_stub = self.swap_with_call_counter( + firebase_auth_services, 'revoke_super_admin_privileges') + + with revoke_super_admin_privileges_stub as call_counter: + response = self.delete_json( + '/adminsuperadminhandler', + params={'username': self.ADMIN_USERNAME}, + expected_status_int=200) + + self.assertEqual(call_counter.times_called, 1) + self.assertNotIn('error', response) + + def test_revoke_super_admin_privileges_requires_system_default_admin(self): + self.login(self.ADMIN_EMAIL, is_super_admin=True) + + revoke_super_admin_privileges_stub = self.swap_with_call_counter( + firebase_auth_services, 'revoke_super_admin_privileges') + + with revoke_super_admin_privileges_stub as call_counter: + response = self.delete_json( + '/adminsuperadminhandler', + params={'username': self.ADMIN_USERNAME}, + expected_status_int=401) + + self.assertEqual(call_counter.times_called, 0) + self.assertEqual( + response['error'], + 'Only the default system admin can manage super admins') + + def test_revoke_super_admin_privileges_fails_without_username(self): + self.login(feconf.ADMIN_EMAIL_ADDRESS, is_super_admin=True) + + response = self.delete_json( + '/adminsuperadminhandler', params={}, expected_status_int=400) + + self.assertEqual(response['error'], 'Missing username param') + + def test_revoke_super_admin_privileges_fails_with_invalid_username(self): + self.login(feconf.ADMIN_EMAIL_ADDRESS, is_super_admin=True) + + response = self.delete_json( + '/adminsuperadminhandler', + params={'username': 'fakeusername'}, expected_status_int=400) + + self.assertEqual(response['error'], 'No such user exists') + + def test_revoke_super_admin_privileges_fails_for_default_admin(self): + self.login(feconf.ADMIN_EMAIL_ADDRESS, is_super_admin=True) + + response = self.delete_json( + '/adminsuperadminhandler', params={'username': 'testsuper'}, + expected_status_int=400) + + self.assertEqual( + response['error'], + 'Cannot revoke privileges from the default super admin account') + class GenerateDummyExplorationsTest(test_utils.GenericTestBase): """Test the conditions for generation of dummy explorations.""" diff --git a/core/controllers/base.py b/core/controllers/base.py index 55ac2456ff28..ec4a6c0adb1c 100755 --- a/core/controllers/base.py +++ b/core/controllers/base.py @@ -63,26 +63,36 @@ def load_template(filename): class SessionBeginHandler(webapp2.RequestHandler): - """Class which handles the creation of a new authentication session.""" + """Handler for creating new authentication sessions.""" def get(self): """Establishes a new auth session.""" auth_services.establish_auth_session(self.request, self.response) -class LogoutPage(webapp2.RequestHandler): - """Class which handles the logout URL.""" +class SessionEndHandler(webapp2.RequestHandler): + """Handler for destroying existing authentication sessions.""" def get(self): - """Logs the user out, and returns them to a specified follow-up - page (or the home page if no follow-up page is specified). - """ - + """Destroys an existing auth session.""" auth_services.destroy_auth_session(self.response) - url_to_redirect_to = ( - python_utils.convert_to_bytes( - self.request.get('redirect_url', '/'))) - self.redirect(url_to_redirect_to) + + +class SeedFirebaseHandler(webapp2.RequestHandler): + """Handler for preparing Firebase and Oppia to run SeedFirebaseOneOffJob. + + TODO(#11462): Delete this handler once the Firebase migration logic is + rollback-safe and all backup data is using post-migration data. + """ + + def get(self): + """Prepares Firebase and Oppia to run SeedFirebaseOneOffJob.""" + try: + auth_services.seed_firebase() + except Exception: + logging.exception('Failed to prepare for SeedFirebaseOneOffJob') + finally: + self.redirect('/') class UserFacingExceptions(python_utils.OBJECT): @@ -164,6 +174,7 @@ def __init__(self, request, response): # pylint: disable=super-init-not-called self.user_id = None self.username = None + self.email = None self.partially_logged_in = False self.user_is_scheduled_for_deletion = False @@ -176,6 +187,8 @@ def __init__(self, request, response): # pylint: disable=super-init-not-called # the not-fully registered user. email = auth_claims.email if 'signup?' in self.request.uri: + if not feconf.ENABLE_USER_CREATION: + raise Exception('New sign-ups are temporarily disabled') user_settings = ( user_services.create_new_user(auth_id, email)) else: @@ -185,6 +198,7 @@ def __init__(self, request, response): # pylint: disable=super-init-not-called auth_services.destroy_auth_session(self.response) return + self.email = user_settings.email self.values['user_email'] = user_settings.email self.user_id = user_settings.user_id diff --git a/core/controllers/base_test.py b/core/controllers/base_test.py index 6e366981effc..daa796c29b26 100644 --- a/core/controllers/base_test.py +++ b/core/controllers/base_test.py @@ -40,6 +40,7 @@ from core.domain import rights_manager from core.domain import user_services from core.platform import models +from core.platform.auth import firebase_auth_services from core.tests import test_utils import feconf import main @@ -709,45 +710,60 @@ def test_downloadable(self): class SessionBeginHandlerTests(test_utils.GenericTestBase): - """Tests for session handler.""" + """Tests for /session_begin handler.""" def test_get(self): - call_counter = test_utils.CallCounter(lambda *_: None) + swap = self.swap_with_call_counter( + auth_services, 'establish_auth_session') - with self.swap(auth_services, 'establish_auth_session', call_counter): + with swap as call_counter: self.get_html_response('/session_begin', expected_status_int=200) self.assertEqual(call_counter.times_called, 1) -class LogoutPageTests(test_utils.GenericTestBase): - """Tests for logout handler.""" +class SessionEndHandlerTests(test_utils.GenericTestBase): + """Tests for /session_end handler.""" - def test_logout_page_calls_destroy_auth_session(self): - exp_services.load_demo('0') - self.get_html_response('/explore/0') + def test_get(self): + swap = ( + self.swap_with_call_counter(auth_services, 'destroy_auth_session')) + + with swap as call_counter: + self.get_html_response('/session_end', expected_status_int=200) + + self.assertEqual(call_counter.times_called, 1) - call_counter = test_utils.CallCounter(lambda _: None) - with self.swap(auth_services, 'destroy_auth_session', call_counter): - # Logout with valid query arg. This test only validates that the - # login cookies have expired after hitting the logout url. - self.get_html_response('/logout', expected_status_int=302) +class SeedFirebaseHandlerTests(test_utils.GenericTestBase): + """Tests for /seed_firebase handler.""" + + def test_get(self): + swap = self.swap_with_call_counter( + firebase_auth_services, 'seed_firebase') + + with swap as call_counter: + response = self.get_html_response( + '/seed_firebase', expected_status_int=302) self.assertEqual(call_counter.times_called, 1) + self.assertEqual(response.location, 'http://localhost/') - def test_logout_page_with_redirect_url(self): - exp_services.load_demo('0') - self.get_html_response('/explore/0') + def test_get_with_error(self): + swap = self.swap_with_call_counter( + firebase_auth_services, 'seed_firebase', raises=Exception()) - response = self.get_html_response( - '/logout?redirect_url=community-library', expected_status_int=302) + captured_logging_context = self.capture_logging(min_level=logging.ERROR) - self.assertIn('community-library', response.headers['Location']) + with swap as call_counter, captured_logging_context as logs: + response = self.get_html_response( + '/seed_firebase', expected_status_int=302) - def test_logout_page_with_dev_mode_disabled(self): - with self.swap(constants, 'DEV_MODE', False): - self.get_html_response('/logout', expected_status_int=302) + self.assertEqual(call_counter.times_called, 1) + self.assertEqual(response.location, 'http://localhost/') + self.assert_matches_regexps(logs, [ + 'Failed to prepare for SeedFirebaseOneOffJob' + ]) class I18nDictsTests(test_utils.GenericTestBase): @@ -970,8 +986,9 @@ class CheckAllHandlersHaveDecoratorTests(test_utils.GenericTestBase): UNDECORATED_HANDLERS = frozenset([ 'CsrfTokenHandler', 'Error404Handler', - 'LogoutPage', 'SessionBeginHandler', + 'SessionEndHandler', + 'SeedFirebaseHandler', ]) def test_every_method_has_decorator(self): @@ -1202,6 +1219,13 @@ def test_no_error_is_raised_on_opening_new_tab_after_signup(self): self.get_html_response('/community-library') + def test_500_error_is_raised_when_enable_user_creation_is_false(self): + self.login('abc@example.com') + + with self.swap(feconf, 'ENABLE_USER_CREATION', False): + response = self.get_response_without_checking_for_errors( + '%s?return_url=/' % feconf.SIGNUP_URL, [500]) + class CsrfTokenHandlerTests(test_utils.GenericTestBase): diff --git a/core/controllers/creator_dashboard_test.py b/core/controllers/creator_dashboard_test.py index 5317a0f6c5e5..6db087d32d08 100644 --- a/core/controllers/creator_dashboard_test.py +++ b/core/controllers/creator_dashboard_test.py @@ -129,7 +129,7 @@ def test_notifications_dashboard_redirects_for_logged_out_users(self): response = self.get_html_response( '/notifications', expected_status_int=302) # This should redirect to the login page. - self.assertIn('signup', response.headers['location']) + self.assertIn('login', response.headers['location']) self.assertIn('notifications', response.headers['location']) self.login('reader@example.com') diff --git a/core/controllers/editor_test.py b/core/controllers/editor_test.py index 96883de6002b..439550d0c7a1 100644 --- a/core/controllers/editor_test.py +++ b/core/controllers/editor_test.py @@ -265,53 +265,6 @@ def test_publish_exploration(self): self.logout() -class ExplorationEditorLogoutTest(BaseEditorControllerTests): - """Test handler for logout from exploration editor page.""" - - def test_logout_from_unpublished_exploration_editor(self): - """Logout from unpublished exploration should redirect - to library page. - """ - - unpublished_exp_id = '_unpublished_eid123' - exploration = exp_domain.Exploration.create_default_exploration( - unpublished_exp_id) - exp_services.save_new_exploration(self.owner_id, exploration) - - current_page_url = '%s/%s' % ( - feconf.EDITOR_URL_PREFIX, unpublished_exp_id) - self.login(self.OWNER_EMAIL) - response = self.get_html_response(current_page_url) - - response = self.get_html_response('/logout', expected_status_int=302) - self.assertEqual(response.status_int, 302) - self.assertEqual(response.headers['location'], 'http://localhost/') - self.logout() - - def test_logout_from_published_exploration_editor(self): - """Logout from published exploration should redirect - to same page. - """ - - published_exp_id = 'published_eid-123' - exploration = exp_domain.Exploration.create_default_exploration( - published_exp_id) - exp_services.save_new_exploration(self.owner_id, exploration) - - current_page_url = '%s/%s' % ( - feconf.EDITOR_URL_PREFIX, published_exp_id) - self.login(self.OWNER_EMAIL) - response = self.get_html_response(current_page_url) - - rights_manager.publish_exploration(self.owner, published_exp_id) - - response = self.get_html_response('/logout', expected_status_int=302) - self.assertEqual(response.status_int, 302) - self.assertEqual( - response.headers['location'], 'http://localhost/') - self.logout() - - class DownloadIntegrationTest(BaseEditorControllerTests): """Test handler for exploration and state download.""" diff --git a/core/domain/auth_jobs_one_off.py b/core/domain/auth_jobs_one_off.py index b6b89bf139e4..9fb45ba38a2a 100644 --- a/core/domain/auth_jobs_one_off.py +++ b/core/domain/auth_jobs_one_off.py @@ -20,7 +20,6 @@ from __future__ import unicode_literals # pylint: disable=import-only-modules import ast -import itertools from core import jobs from core.domain import auth_domain @@ -29,66 +28,202 @@ from core.platform.auth import gae_auth_services import feconf import python_utils +import utils import firebase_admin from firebase_admin import auth as firebase_auth -from firebase_admin import exceptions as firebase_exceptions + +auth_models, user_models = ( + models.Registry.import_models([models.NAMES.auth, models.NAMES.user])) ID_HASHING_FUNCTION = hash -MAX_USERS_FIREBASE_CAN_IMPORT_PER_CALL = 1000 -AUDIT_KEY = 'INFO: Pre-existing Firebase accounts' -FAILURE_KEY = 'FAILURE: Failed to create Firebase accounts' -SUCCESS_KEY = 'SUCCESS: Created Firebase accounts' -WARNING_KEY = 'WARNING: No action needed' +class SeedFirebaseOneOffJob(jobs.BaseMapReduceOneOffJobManager): + """Brings Firebase accounts and association models to a deterministic state. + + The following pre-conditions must hold for no errors to occur (accomplished + by calling the firebase_auth_services.seed_firebase() function): + 1. Exactly one FirebaseSeedModel must exist. + 2. feconf.ADMIN_EMAIL_ADDRESS must correspond to a UserAuthDetailsModel + where: + firebase_auth_id is not None AND + gae_id != feconf.SYSTEM_COMMITTER_ID. + 3. feconf.ADMIN_EMAIL_ADDRESS must correspond to exactly one + UserIdByFirebaseAuthIdModel where: + id equals the aforementioned firebase_auth_id AND + user_id is the ID of the aforementioned UserAuthDetailsModel. + 4. feconf.ADMIN_EMAIL_ADDRESS must correspond to exactly one Firebase + account where: + uid is equal to the aforementioned firebase_auth_id AND + custom_claims == {"role":"super_admin"}. + + The following post-conditions will hold if no errors occur: + 1. Exactly one Firebase account will exist: feconf.ADMIN_EMAIL_ADDRESS. + 2. Exactly one UserIdByFirebaseAuthIdModel model will exist. + 3. Exactly one UserAuthDetailsModel will have a non-None + firebase_auth_id value. + """ -SYSTEM_COMMITTER_ACK = 'INFO: SYSTEM_COMMITTER_ID skipped' + ASSOC_MODEL_TYPES = ( + auth_models.UserAuthDetailsModel, + auth_models.UserIdByFirebaseAuthIdModel) -POPULATED_KEY = 'ALREADY_DONE' -NOT_POPULATED_KEY = 'NEEDS_WORK' + INFO_SUPER_ADMIN_ACK = 'INFO: Found feconf.ADMIN_EMAIL_ADDRESS' + INFO_SYSTEM_COMMITTER_ACK = 'INFO: Found feconf.SYSTEM_COMMITTER_ID' + INFO_SEED_MODEL_ACK = 'INFO: Found FirebaseSeedModel' -auth_models, user_models = ( - models.Registry.import_models([models.NAMES.auth, models.NAMES.user])) + ERROR_BATCH_DELETE = 'ERROR: Failed to delete a batch of Firebase accounts' + ERROR_INDIVIDUAL_DELETE = ( + 'ERROR: Failed to delete an individual Firebase account') + SUCCESS_DELETE_ACCOUNTS = 'SUCCESS: Firebase accounts deleted' + SUCCESS_DELETE_ASSOC_TEMPLATE = 'SUCCESS: %s wiped' + SUCCESS_ALREADY_DELETED_ASSOC_TEMPLATE = 'SUCCESS: %s already wiped' -class AuditFirebaseImportReadinessOneOffJob(jobs.BaseMapReduceOneOffJobManager): - """One-off job to confirm whether users are ready for Firebase import.""" + MAX_USERS_FIREBASE_CAN_DELETE_PER_CALL = 1000 @classmethod def entity_classes_to_map_over(cls): - return [user_models.UserSettingsModel] + return [ + auth_models.FirebaseSeedModel, + auth_models.UserAuthDetailsModel, + auth_models.UserIdByFirebaseAuthIdModel, + ] @staticmethod - def map(user): - gae_auth_id = gae_auth_services.get_auth_id_from_user_id(user.id) - # NOTE: This committer ID is a legacy ACL-bypass that we no longer - # depend on. Because it is obsolete, we do not want it to have a - # Firebase account associated with it, or even consider it for import. - if gae_auth_id == feconf.SYSTEM_COMMITTER_ID: - yield (SYSTEM_COMMITTER_ACK, user.id) + def map(item): + # The map() function must be static, so we manually create a "cls" + # variable instead of changing the function into a classmethod. + cls = SeedFirebaseOneOffJob + + if isinstance(item, cls.ASSOC_MODEL_TYPES): + admin_ack = cls.get_admin_ack(item) + if admin_ack is not None: + yield admin_ack + else: + yield (cls.wipe_assoc_model(item), 1) return - if user.deleted: - yield ('[DELETED]', user.id) - else: - yield (user.email, user.id) + yield (cls.INFO_SEED_MODEL_ACK, item.id) + + for user_batch in cls.yield_firebase_user_batches(): + admins_to_ack, users_to_delete = utils.partition( + user_batch, + predicate=lambda user: user.email == feconf.ADMIN_EMAIL_ADDRESS) + + for user in admins_to_ack: + yield ( + '%s in Firebase account' % cls.INFO_SUPER_ADMIN_ACK, + 'firebase_auth_id=%s' % (user.uid)) + + ids_to_delete = [user.uid for user in users_to_delete] + try: + result = firebase_admin.auth.delete_users( + ids_to_delete, force_delete=True) + except Exception as exception: + yield (cls.ERROR_BATCH_DELETE, len(ids_to_delete)) + yield (cls.ERROR_BATCH_DELETE, 'reason=%r' % exception) + else: + for error in result.errors: + firebase_auth_id = ids_to_delete[error.index] + debug_info = 'firebase_auth_id=%s, reason=%s' % ( + firebase_auth_id, error.reason) + yield (cls.ERROR_INDIVIDUAL_DELETE, debug_info) + num_deleted = len(ids_to_delete) - len(result.errors) + if num_deleted: + yield (cls.SUCCESS_DELETE_ACCOUNTS, num_deleted) @staticmethod def reduce(key, values): - # NOTE: These are only sorted to make unit tests simpler. - if key == SYSTEM_COMMITTER_ACK: - yield (SYSTEM_COMMITTER_ACK, values) - return + # The reduce() function must be static, so we manually create a "cls" + # variable instead of changing the function into a classmethod. + cls = SeedFirebaseOneOffJob + + if key.startswith('SUCCESS:'): + yield (key, sum(int(v) for v in values)) + elif key == cls.ERROR_BATCH_DELETE: + reasons, counts = utils.partition( + values, predicate=lambda value: value.startswith('reason=')) + debug_info = 'count=%d, reasons=[%s]' % ( + sum(int(c) for c in counts), + ', '.join(sorted({r[7:] for r in reasons}))) + yield (key, debug_info) + else: + yield (key, values) - joined_user_ids = ', '.join(sorted(values)) + @classmethod + def yield_firebase_user_batches(cls): + """Yields every single Firebase account in batches.""" + # 1000 is the maximum amount of users that can be deleted at once. + page = firebase_admin.auth.list_users( + max_results=cls.MAX_USERS_FIREBASE_CAN_DELETE_PER_CALL) + while page is not None: + user_batch = page.users + if not user_batch: + break + yield user_batch + page = page.get_next_page() - if key == '[DELETED]': - yield ('ERROR: Found deleted users', joined_user_ids) + @classmethod + def get_admin_ack(cls, item): + """Returns an acknowledgement key if the item is associated to an admin. + + Args: + item: UserAuthDetailsModel|UserIdByFirebaseAuthIdModel. The item to + check. + + Returns: + str|None. A key acknowledging a super admin, or None if the item + does not correspond to a super admin. + """ + model_name = type(item).__name__ + + if isinstance(item, auth_models.UserAuthDetailsModel): + user_id = item.id + gae_auth_id = item.gae_id else: - email = key - if len(values) > 1: - yield ('ERROR: %s is a shared email' % email, joined_user_ids) + user_id = item.user_id + gae_auth_id = auth_models.UserAuthDetailsModel.get(user_id).gae_id + + if gae_auth_id == feconf.SYSTEM_COMMITTER_ID: + return ( + '%s in %s' % (cls.INFO_SYSTEM_COMMITTER_ACK, model_name), + 'user_id=%s' % user_id) + + user_settings_model = user_models.UserSettingsModel.get(user_id) + if user_settings_model.email == feconf.ADMIN_EMAIL_ADDRESS: + return ( + '%s in %s' % (cls.INFO_SUPER_ADMIN_ACK, model_name), + 'user_id=%s' % user_id) + + return None + + @classmethod + def wipe_assoc_model(cls, item): + """Wipes the given model of Firebase account associations. + + Args: + item: UserAuthDetailsModel|UserIdByFirebaseAuthIdModel. The item to + wipe. + + Returns: + str. The reduce key to yield from the map() function for further + processing. + """ + model_name = type(item).__name__ + + if isinstance(item, auth_models.UserAuthDetailsModel): + if item.firebase_auth_id is None: + return cls.SUCCESS_ALREADY_DELETED_ASSOC_TEMPLATE % model_name + else: + item.firebase_auth_id = None + item.update_timestamps(update_last_updated_time=False) + item.put() + else: + item.delete() + + return cls.SUCCESS_DELETE_ASSOC_TEMPLATE % model_name class PopulateFirebaseAccountsOneOffJob(jobs.BaseMapReduceOneOffJobManager): @@ -99,9 +234,23 @@ class PopulateFirebaseAccountsOneOffJob(jobs.BaseMapReduceOneOffJobManager): NOTE: **DO NOT** ASSUME THAT FIREBASE IDS AND OPPIA USER IDS WILL BE THE SAME! We are only doing this for users that already exist; future users that sign up with Firebase will have an entirely different ID. + + DO NOT START THIS JOB UNTIL SeedFirebaseOneOffJob COMPLETES WITHOUT ANY + ERRORS! """ NUM_SHARDS = 50 # Arbitrary value. + MAX_USERS_FIREBASE_CAN_IMPORT_PER_CALL = 1000 + + AUDIT_KEY = 'INFO: Pre-existing Firebase accounts' + ERROR_KEY = 'ERROR: Failed to create Firebase accounts' + SUCCESS_KEY = 'SUCCESS: Created Firebase accounts' + WARNING_KEY = 'WARNING: No action needed' + + SUPER_ADMIN_ACK = 'INFO: Super admin created' + SYSTEM_COMMITTER_ACK = 'INFO: SYSTEM_COMMITTER_ID skipped' + + POPULATED_KEY = 'ALREADY_DONE' @classmethod def entity_classes_to_map_over(cls): @@ -109,6 +258,10 @@ def entity_classes_to_map_over(cls): @staticmethod def map(user): + # The map() function must be static, so we manually create a "cls" + # variable instead of changing the function into a classmethod. + cls = PopulateFirebaseAccountsOneOffJob + if user.deleted: return @@ -117,69 +270,69 @@ def map(user): # depend on. Because it is obsolete, we do not want it to have a # Firebase account associated with it. if gae_auth_id == feconf.SYSTEM_COMMITTER_ID: - yield (SYSTEM_COMMITTER_ACK, user.id) + yield (cls.SYSTEM_COMMITTER_ACK, user.id) return auth_id = firebase_auth_services.get_auth_id_from_user_id(user.id) if auth_id is not None: - yield (POPULATED_KEY, 1) + yield (cls.POPULATED_KEY, 1) else: + user_is_super_admin = (user.email == feconf.ADMIN_EMAIL_ADDRESS) + if user_is_super_admin: + yield (cls.SUPER_ADMIN_ACK, user.id) + # Split up users into different shards to help speed up the job. - sharding_key = ( - ID_HASHING_FUNCTION(user.id) % - PopulateFirebaseAccountsOneOffJob.NUM_SHARDS) + sharding_key = ID_HASHING_FUNCTION(user.id) % cls.NUM_SHARDS yield ( - sharding_key, (_strip_uid_prefix(user.id), user.id, user.email)) + sharding_key, ( + cls.strip_uid_prefix(user.id), user.id, user.email, + user_is_super_admin)) @staticmethod def reduce(key, values): - if key == POPULATED_KEY: - yield (AUDIT_KEY, len(values)) - return - elif key == SYSTEM_COMMITTER_ACK: - yield (SYSTEM_COMMITTER_ACK, values) - return + # The reduce() function must be static, so we manually create a "cls" + # variable instead of changing the function into a classmethod. + cls = PopulateFirebaseAccountsOneOffJob - try: - # NOTE: "app" is the term Firebase uses for the "entry point" to the - # Firebase SDK. Oppia only has one server, so it only needs to - # instantiate one app. - firebase_connection = firebase_admin.initialize_app() - except Exception as exception: - yield (WARNING_KEY, repr(exception)) + if key == cls.POPULATED_KEY: + yield (cls.AUDIT_KEY, len(values)) + return + elif key in (cls.SUPER_ADMIN_ACK, cls.SYSTEM_COMMITTER_ACK): + yield (key, values) return # NOTE: This is only sorted to make unit testing easier. user_fields = sorted(ast.literal_eval(v) for v in values) user_records = [ firebase_auth.ImportUserRecord( - uid=auth_id, email=email, email_verified=True) - for auth_id, _, email in user_fields + uid=auth_id, email=email, email_verified=True, custom_claims=( + '{"role":"%s"}' % feconf.FIREBASE_ROLE_SUPER_ADMIN + if user_is_super_admin else None)) + for auth_id, _, email, user_is_super_admin in user_fields ] # The Firebase Admin SDK places a hard-limit on the number of users that # can be "imported" in a single call. To compensate, we break up the # users into chunks. offsets = python_utils.RANGE( - 0, len(user_records), MAX_USERS_FIREBASE_CAN_IMPORT_PER_CALL) + 0, len(user_records), cls.MAX_USERS_FIREBASE_CAN_IMPORT_PER_CALL) results = ( - _populate_firebase([record for record in record_group if record]) - for record_group in _grouper( - user_records, MAX_USERS_FIREBASE_CAN_IMPORT_PER_CALL)) + cls.populate_firebase([r for r in record_group if r is not None]) + for record_group in utils.grouper( + user_records, cls.MAX_USERS_FIREBASE_CAN_IMPORT_PER_CALL)) assocs_to_create = [] for offset, (result, exception) in python_utils.ZIP(offsets, results): if exception is not None: - yield (FAILURE_KEY, repr(exception)) + yield (cls.ERROR_KEY, repr(exception)) else: successful_indices = set(python_utils.RANGE( result.success_count + result.failure_count)) for error in result.errors: successful_indices.remove(error.index) - debug_info = ( - 'Import user_id=%r failed: %s' % ( - user_fields[offset + error.index][1], error.reason)) - yield (FAILURE_KEY, debug_info) + debug_info = 'Import user_id=%r failed: %s' % ( + user_fields[offset + error.index][1], error.reason) + yield (cls.ERROR_KEY, debug_info) assocs_to_create.extend( auth_domain.AuthIdUserIdPair(*user_fields[offset + i][:2]) for i in successful_indices) @@ -187,57 +340,27 @@ def reduce(key, values): if assocs_to_create: firebase_auth_services.associate_multi_auth_ids_with_user_ids( assocs_to_create) - yield (SUCCESS_KEY, len(assocs_to_create)) + yield (cls.SUCCESS_KEY, len(assocs_to_create)) + @classmethod + def populate_firebase(cls, user_records): + """Populates the Firebase server with the given user records. + + Args: + user_records: list(firebase_admin.auth.ImportUserRecord). Users to + store in Firebase. + + Returns: + tuple(UserImportResult|None, Exception|None). The result of the + operation, or an exception if the operation failed. Exactly one of + the values will be non-None. + """ try: - # NOTE: This is not dangerous. We are just deleting the resources - # used to form a connection to Firebase servers. - firebase_admin.delete_app(firebase_connection) + return (firebase_auth.import_users(user_records), None) except Exception as exception: - yield (WARNING_KEY, repr(exception)) + return (None, exception) - -def _grouper(iterable, chunk_len, fillvalue=None): - """Collect data into fixed-length chunks. - - Source: https://docs.python.org/3/library/itertools.html#itertools-recipes. - - Example: - grouper('ABCDEFG', 3, 'x') --> ABC DEF Gxx" - - Args: - iterable: iterable. Any kind of iterable object. - chunk_len: int. The chunk size to group values. - fillvalue: *. The value used to fill out the last chunk when the - iterable is exhausted. - - Returns: - iterable(iterable). A sequence of chunks over the input data. - """ - # To understand how/why this works, please refer to the following - # stackoverflow post: https://stackoverflow.com/a/49181132/4859885. - args = [iter(iterable)] * chunk_len - return itertools.izip_longest(*args, fillvalue=fillvalue) # pylint: disable=deprecated-itertools-function - - -def _populate_firebase(user_records): - """Populates the Firebase server with the given user records. - - Args: - user_records: list(firebase_admin.auth.ImportUserRecord). Users to store - in Firebase. - - Returns: - tuple(UserImportResult|None, Exception|None). The result of the - operation, or an exception if the operation failed. Exactly one of the - values will be non-None. - """ - try: - return (firebase_auth.import_users(user_records), None) - except firebase_exceptions.FirebaseError as exception: - return (None, exception) - - -def _strip_uid_prefix(user_id): - """Removes the 'uid_' prefix from a user_id and returns the result.""" - return user_id[4:] if user_id.startswith('uid_') else user_id + @classmethod + def strip_uid_prefix(cls, user_id): + """Removes the 'uid_' prefix from a user_id and returns the result.""" + return user_id[4:] if user_id.startswith('uid_') else user_id diff --git a/core/domain/auth_jobs_one_off_test.py b/core/domain/auth_jobs_one_off_test.py index 529827b04fa4..5298ceb5048e 100644 --- a/core/domain/auth_jobs_one_off_test.py +++ b/core/domain/auth_jobs_one_off_test.py @@ -29,20 +29,38 @@ from core.platform import models from core.platform.auth import firebase_auth_services from core.platform.auth import firebase_auth_services_test +from core.platform.auth import gae_auth_services from core.tests import test_utils import feconf import python_utils import contextlib2 +import firebase_admin.auth auth_models, user_models = ( models.Registry.import_models([models.NAMES.auth, models.NAMES.user])) +datastore_services = models.Registry.import_datastore_services() -class AuditFirebaseImportReadinessOneOffJobTests(test_utils.AppEngineTestBase): + +class FirebaseOneOffJobTestBase(test_utils.AppEngineTestBase): + """Base class for Firebase-dependent one-off jobs.""" AUTO_CREATE_DEFAULT_SUPERADMIN_USER = False + def setUp(self): + super(FirebaseOneOffJobTestBase, self).setUp() + self.exit_stack = contextlib2.ExitStack() + self.firebase_sdk_stub = ( + firebase_auth_services_test.FirebaseAdminSdkStub()) + + self.firebase_sdk_stub.install(self) + self.exit_stack.callback(self.firebase_sdk_stub.uninstall) + + def tearDown(self): + self.exit_stack.close() + super(FirebaseOneOffJobTestBase, self).tearDown() + def count_one_off_jobs_in_queue(self): """Returns the number of one off jobs in the taskqueue.""" return self.count_jobs_in_mapreduce_taskqueue( @@ -54,137 +72,186 @@ def run_one_off_job(self): Returns: *. The output of the one off job. """ - job_id = auth_jobs.AuditFirebaseImportReadinessOneOffJob.create_new() + job_id = self.JOB_CLASS.create_new() self.assertEqual(self.count_one_off_jobs_in_queue(), 0) - auth_jobs.AuditFirebaseImportReadinessOneOffJob.enqueue(job_id) + self.JOB_CLASS.enqueue(job_id) self.assertEqual(self.count_one_off_jobs_in_queue(), 1) self.process_and_flush_pending_mapreduce_tasks() self.assertEqual(self.count_one_off_jobs_in_queue(), 0) return sorted( - ast.literal_eval(o) for o in - auth_jobs.AuditFirebaseImportReadinessOneOffJob.get_output(job_id)) + ast.literal_eval(o) for o in self.JOB_CLASS.get_output(job_id)) - def create_user(self, user_id, email, deleted=False): - """Creates a new user with the provided ID and email address. + def create_user_auth_models( + self, user_id, email=None, firebase_auth_id=None, gae_id=None, + deleted=False): + """Adds the minimum model set necessary for a user account. Args: - user_id: str. The user's ID. - email: str. The user's email address. - deleted: bool. Value for the user's deleted property. + user_id: str. The Oppia ID of the user. + email: str|None. The email address of the user. If None, one will be + generated. + firebase_auth_id: str|None. The Firebase account ID of the user. If + None, no Firebase-related models will be created. + gae_id: str|None. The GAE ID of the user. If None, no GAE-related + models will be created. + deleted: bool. Value for the deleted property of the models. """ - user_models.UserSettingsModel( - id=user_id, email=email, deleted=deleted, - role=feconf.ROLE_ID_EXPLORATION_EDITOR, - preferred_language_codes=[constants.DEFAULT_LANGUAGE_CODE] - ).put() + if email is None: + email = '%s@example.com' % user_id + + models_to_put = [ + user_models.UserSettingsModel(id=user_id, email=email), + auth_models.UserAuthDetailsModel( + id=user_id, firebase_auth_id=firebase_auth_id, gae_id=gae_id, + deleted=deleted), + ] + if firebase_auth_id is not None: + models_to_put.append( + auth_models.UserIdByFirebaseAuthIdModel( + id=firebase_auth_id, user_id=user_id, + deleted=deleted)) + if gae_id is not None: + models_to_put.append( + auth_models.UserIdentifiersModel( + id=gae_id, user_id=user_id, deleted=deleted)) + + datastore_services.put_multi(models_to_put) + + def assert_firebase_assoc_exists(self, firebase_auth_id, user_id): + """Asserts that the given user's Firebase association exists. - def test_users_with_distinct_emails_returns_empty_output(self): - self.create_user('u1', 'u1@test.com') - self.create_user('u2', 'u2@test.com') + Args: + user_id: str. The Oppia ID of the user. + firebase_auth_id: str. The Firebase account ID of the user. + """ + self.assertEqual( + firebase_auth_services.get_auth_id_from_user_id(user_id), + firebase_auth_id) + self.assertEqual( + firebase_auth_services.get_user_id_from_auth_id(firebase_auth_id), + user_id) - self.assertEqual(self.run_one_off_job(), []) + def assert_firebase_assoc_does_not_exist(self, firebase_auth_id, user_id): + """Asserts that the given user's Firebase association doesn't exist. - def test_users_with_same_email_are_reported(self): - self.create_user('u1', 'a@test.com') - self.create_user('u2', 'a@test.com') + Args: + firebase_auth_id: str. The Firebase account ID of the user. + user_id: str. The Oppia ID of the user. + """ + self.assertIsNone( + firebase_auth_services.get_auth_id_from_user_id(user_id)) + self.assertIsNone( + firebase_auth_services.get_user_id_from_auth_id(firebase_auth_id)) - self.assertEqual(self.run_one_off_job(), [ - ['ERROR: a@test.com is a shared email', 'u1, u2'], - ]) + def assert_firebase_assoc_exists_multi(self, assocs): + """Asserts that the given users' Firebase association exists. - def test_deleted_users_are_reported(self): - self.create_user('u1', 'u1@test.com', deleted=True) - self.create_user('u2', 'u2@test.com', deleted=True) - self.create_user('u3', 'u3@test.com', deleted=False) + Args: + assocs: list(AuthIdUserIdPair). The associations to check. + """ + auth_ids, user_ids = (list(a) for a in python_utils.ZIP(*assocs)) + self.assertEqual( + firebase_auth_services.get_multi_auth_ids_from_user_ids(user_ids), + auth_ids) + self.assertEqual( + firebase_auth_services.get_multi_user_ids_from_auth_ids(auth_ids), + user_ids) - self.assertEqual(self.run_one_off_job(), [ - ['ERROR: Found deleted users', 'u1, u2'], - ]) + def assert_firebase_assoc_does_not_exist_multi(self, assocs): + """Asserts that the given users' Firebase association doesn't exist. - def test_system_committer_is_ignored_by_duplicate_email_check(self): - self.create_user('xx', 'admin@test.com') - self.create_user('yy', 'admin@test.com') - auth_models.UserAuthDetailsModel( - id='xx', gae_id=feconf.SYSTEM_COMMITTER_ID - ).put() - auth_models.UserIdentifiersModel( - id=feconf.SYSTEM_COMMITTER_ID, user_id='xx' - ).put() + Args: + assocs: list(AuthIdUserIdPair). The associations to check. + """ + auth_ids, user_ids = (list(a) for a in python_utils.ZIP(*assocs)) + self.assertEqual( + firebase_auth_services.get_multi_user_ids_from_auth_ids(auth_ids), + [None] * len(auth_ids)) + self.assertEqual( + firebase_auth_services.get_multi_auth_ids_from_user_ids(user_ids), + [None] * len(user_ids)) - self.assertEqual(self.run_one_off_job(), [ - ['INFO: SYSTEM_COMMITTER_ID skipped', ['xx']], - ]) + def assert_gae_assoc_exists(self, gae_id, user_id): + """Asserts that the given user's GAE association exists. - def test_system_committer_is_ignored_by_deleted_check(self): - self.create_user('u1', 'admin@test.com', deleted=True) - auth_models.UserAuthDetailsModel( - id='u1', gae_id=feconf.SYSTEM_COMMITTER_ID - ).put() - auth_models.UserIdentifiersModel( - id=feconf.SYSTEM_COMMITTER_ID, user_id='u1' - ).put() + Args: + user_id: str. The Oppia ID of the user. + gae_id: str. The GAE ID of the user. + """ + self.assertEqual( + gae_auth_services.get_auth_id_from_user_id(user_id), gae_id) + self.assertEqual( + gae_auth_services.get_user_id_from_auth_id(gae_id), user_id) - self.assertEqual(self.run_one_off_job(), [ - ['INFO: SYSTEM_COMMITTER_ID skipped', ['u1']], - ]) + def assert_gae_assoc_does_not_exist(self, gae_id, user_id): + """Asserts that the given user's GAE association doesn't exist. + Args: + user_id: str. The Oppia ID of the user. + gae_id: str. The GAE ID of the user. + """ + self.assertIsNone(gae_auth_services.get_auth_id_from_user_id(user_id)) + self.assertIsNone(gae_auth_services.get_user_id_from_auth_id(gae_id)) + + def assert_gae_assoc_exists_multi(self, assocs): + """Asserts that the given users' GAE association exists. + + Args: + assocs: list(AuthIdUserIdPair). The associations to check. + """ + auth_ids, user_ids = (list(a) for a in python_utils.ZIP(*assocs)) + self.assertEqual( + gae_auth_services.get_multi_auth_ids_from_user_ids(user_ids), + auth_ids) + self.assertEqual( + gae_auth_services.get_multi_user_ids_from_auth_ids(auth_ids), + user_ids) + + def assert_gae_assoc_does_not_exist_multi(self, assocs): + """Asserts that the given users' GAE association doesn't exist. + + Args: + assocs: list(AuthIdUserIdPair). The associations to check. + """ + auth_ids, user_ids = (list(a) for a in python_utils.ZIP(*assocs)) + self.assertEqual( + gae_auth_services.get_multi_user_ids_from_auth_ids(auth_ids), + [None] * len(auth_ids)) + self.assertEqual( + gae_auth_services.get_multi_auth_ids_from_user_ids(user_ids), + [None] * len(user_ids)) -class PopulateFirebaseAccountsOneOffJobTests(test_utils.AppEngineTestBase): + +class PopulateFirebaseAccountsOneOffJobTests(FirebaseOneOffJobTestBase): AUTO_CREATE_DEFAULT_SUPERADMIN_USER = False + JOB_CLASS = auth_jobs.PopulateFirebaseAccountsOneOffJob + def setUp(self): super(PopulateFirebaseAccountsOneOffJobTests, self).setUp() self._auth_id_generator = itertools.count() - self.exit_stack = contextlib2.ExitStack() - self.sdk_stub = firebase_auth_services_test.FirebaseAdminSdkStub() - - self.sdk_stub.install(self) - self.exit_stack.callback(self.sdk_stub.uninstall) - # Forces all users to produce the same hash value during unit tests to # prevent them from being sharded and complicating the testing logic. self.exit_stack.enter_context(self.swap_to_always_return( auth_jobs, 'ID_HASHING_FUNCTION', value=1)) - def tearDown(self): - self.exit_stack.close() - super(PopulateFirebaseAccountsOneOffJobTests, self).tearDown() - - def count_one_off_jobs_in_queue(self): - """Returns the number of one off jobs in the taskqueue.""" - return self.count_jobs_in_mapreduce_taskqueue( - taskqueue_services.QUEUE_NAME_ONE_OFF_JOBS) - - def run_one_off_job(self): - """Begins the one off job and asserts it completes as expected. - - Returns: - *. The output of the one off job. - """ - job_id = auth_jobs.PopulateFirebaseAccountsOneOffJob.create_new() - self.assertEqual(self.count_one_off_jobs_in_queue(), 0) - auth_jobs.PopulateFirebaseAccountsOneOffJob.enqueue(job_id) - self.assertEqual(self.count_one_off_jobs_in_queue(), 1) - self.process_and_flush_pending_mapreduce_tasks() - self.assertEqual(self.count_one_off_jobs_in_queue(), 0) - return sorted( - ast.literal_eval(o) for o in - auth_jobs.PopulateFirebaseAccountsOneOffJob.get_output(job_id)) - - def create_oppia_user(self, deleted=False): + def create_oppia_user(self, email=None, deleted=False): """Returns an (auth_id, user_id) pair for a new user. Args: + email: str. The email address of the user. deleted: bool. Value for the user's deleted property. Returns: AuthIdUserIdPair. The association the user should create. """ - auth_id = 'aid%d' % python_utils.NEXT(self._auth_id_generator) + auth_id = 'aid_index_%d' % python_utils.NEXT(self._auth_id_generator) user_id = 'uid_%s' % auth_id + if email is None: + email = 'email_%s@test.com' % auth_id user_models.UserSettingsModel( - id=user_id, email=('email_%s@test.com' % auth_id), deleted=deleted, + id=user_id, email=email, deleted=deleted, role=feconf.ROLE_ID_EXPLORATION_EDITOR, preferred_language_codes=[constants.DEFAULT_LANGUAGE_CODE] ).put() @@ -202,58 +269,6 @@ def create_multi_oppia_users(self, count): """ return [self.create_oppia_user() for _ in python_utils.RANGE(count)] - def assert_auth_mapping_exists(self, auth_assoc): - """Asserts that the given auth association exists. - - Args: - auth_assoc: AuthIdUserIdPair. The association to check. - """ - auth_id, user_id = auth_assoc - self.assertEqual( - firebase_auth_services.get_auth_id_from_user_id(user_id), auth_id) - self.assertEqual( - firebase_auth_services.get_user_id_from_auth_id(auth_id), user_id) - - def assert_auth_mapping_does_not_exist(self, auth_assoc): - """Asserts that the given auth association does not exist. - - Args: - auth_assoc: AuthIdUserIdPair. The association to check. - """ - auth_id, user_id = auth_assoc - self.assertIsNone( - firebase_auth_services.get_auth_id_from_user_id(user_id)) - self.assertIsNone( - firebase_auth_services.get_user_id_from_auth_id(auth_id)) - - def assert_multi_auth_mappings_exist(self, auth_assocs): - """Asserts that the given auth associations exist. - - Args: - auth_assocs: list(AuthIdUserIdPair). The association to check. - """ - auth_ids, user_ids = (list(a) for a in python_utils.ZIP(*auth_assocs)) - self.assertEqual( - firebase_auth_services.get_multi_auth_ids_from_user_ids(user_ids), - auth_ids) - self.assertEqual( - firebase_auth_services.get_multi_user_ids_from_auth_ids(auth_ids), - user_ids) - - def assert_multi_auth_mappings_do_not_exist(self, auth_assocs): - """Asserts that the given auth associations exist. - - Args: - auth_assocs: list(AuthIdUserIdPair). The association to check. - """ - auth_ids, user_ids = (list(a) for a in python_utils.ZIP(*auth_assocs)) - self.assertEqual( - firebase_auth_services.get_multi_user_ids_from_auth_ids(auth_ids), - [None] * len(auth_ids)) - self.assertEqual( - firebase_auth_services.get_multi_auth_ids_from_user_ids(user_ids), - [None] * len(user_ids)) - def test_successfully_imports_one_user(self): auth_assoc = self.create_oppia_user() @@ -261,16 +276,17 @@ def test_successfully_imports_one_user(self): ['SUCCESS: Created Firebase accounts', 1], ]) - self.assert_auth_mapping_exists(auth_assoc) - self.sdk_stub.assert_firebase_user_exists(auth_assoc.auth_id) + self.assert_firebase_assoc_exists(*auth_assoc) + self.firebase_sdk_stub.assert_is_user(auth_assoc.auth_id) self.assertItemsEqual(self.run_one_off_job(), [ ['INFO: Pre-existing Firebase accounts', 1], ]) def test_successfully_imports_users_in_bulk(self): - self.exit_stack.enter_context( - self.swap(auth_jobs, 'MAX_USERS_FIREBASE_CAN_IMPORT_PER_CALL', 3)) + self.exit_stack.enter_context(self.swap( + auth_jobs.PopulateFirebaseAccountsOneOffJob, + 'MAX_USERS_FIREBASE_CAN_IMPORT_PER_CALL', 3)) auth_assocs = self.create_multi_oppia_users(11) @@ -278,8 +294,8 @@ def test_successfully_imports_users_in_bulk(self): ['SUCCESS: Created Firebase accounts', 11], ]) - self.assert_multi_auth_mappings_exist(auth_assocs) - self.sdk_stub.assert_multi_firebase_users_exist( + self.assert_firebase_assoc_exists_multi(auth_assocs) + self.firebase_sdk_stub.assert_is_user_multi( [a.auth_id for a in auth_assocs]) self.assertItemsEqual(self.run_one_off_job(), [ @@ -291,87 +307,57 @@ def test_skips_deleted_users(self): self.assertItemsEqual(self.run_one_off_job(), []) - def test_initialize_app_error_is_reported(self): - self.exit_stack.enter_context(self.sdk_stub.mock_initialize_app_error()) - - auth_assoc = self.create_oppia_user() - - self.assertItemsEqual(self.run_one_off_job(), [ - ['WARNING: No action needed', - 'UnknownError(u\'could not init\',)'], - ]) - - self.assert_auth_mapping_does_not_exist(auth_assoc) - self.sdk_stub.assert_firebase_user_does_not_exist(auth_assoc.auth_id) - - def test_delete_app_error_is_reported(self): - self.exit_stack.enter_context(self.sdk_stub.mock_delete_app_error()) - - auth_assoc = self.create_oppia_user() - - self.assertItemsEqual(self.run_one_off_job(), [ - ['SUCCESS: Created Firebase accounts', 1], - ['WARNING: No action needed', - 'UnknownError(u\'could not delete app\',)'], - ]) - - # Deleting the app should not be a fatal error, so we should still - # create a firebase account and an association. - self.assert_auth_mapping_exists(auth_assoc) - self.sdk_stub.assert_firebase_user_exists(auth_assoc.auth_id) - - self.assertItemsEqual(self.run_one_off_job(), [ - ['INFO: Pre-existing Firebase accounts', 1], - ]) - def test_import_user_error_is_reported(self): - mock_import_users_error = self.sdk_stub.mock_import_users_error( - call_error_sequence=(True,)) # Always raise an exception. + mock_import_users_error = ( + self.firebase_sdk_stub.mock_import_users_error( + batch_error_pattern=[Exception('uh-oh!')])) auth_assoc = self.create_oppia_user() with mock_import_users_error: self.assertItemsEqual(self.run_one_off_job(), [ - ['FAILURE: Failed to create Firebase accounts', - 'DataLossError(u\'Failed to connect\',)'], + ['ERROR: Failed to create Firebase accounts', + 'Exception(u\'uh-oh!\',)'], ]) - self.assert_auth_mapping_does_not_exist(auth_assoc) - self.sdk_stub.assert_firebase_user_does_not_exist(auth_assoc.auth_id) + self.assert_firebase_assoc_does_not_exist(*auth_assoc) + self.firebase_sdk_stub.assert_is_not_user(auth_assoc.auth_id) self.assertItemsEqual(self.run_one_off_job(), [ ['SUCCESS: Created Firebase accounts', 1], ]) - self.assert_auth_mapping_exists(auth_assoc) - self.sdk_stub.assert_firebase_user_exists(auth_assoc.auth_id) + self.assert_firebase_assoc_exists(*auth_assoc) + self.firebase_sdk_stub.assert_is_user(auth_assoc.auth_id) self.assertItemsEqual(self.run_one_off_job(), [ ['INFO: Pre-existing Firebase accounts', 1], ]) def test_single_import_batch_error_is_reported(self): - self.exit_stack.enter_context( - self.swap(auth_jobs, 'MAX_USERS_FIREBASE_CAN_IMPORT_PER_CALL', 3)) - mock_import_users_error = self.sdk_stub.mock_import_users_error( - call_error_sequence=(False, True, False)) + self.exit_stack.enter_context(self.swap( + auth_jobs.PopulateFirebaseAccountsOneOffJob, + 'MAX_USERS_FIREBASE_CAN_IMPORT_PER_CALL', 3)) + mock_import_users_error = ( + self.firebase_sdk_stub.mock_import_users_error( + batch_error_pattern=[None, Exception('uh-oh!'), None])) auth_assocs = self.create_multi_oppia_users(9) with mock_import_users_error: self.assertItemsEqual(self.run_one_off_job(), [ - ['FAILURE: Failed to create Firebase accounts', - 'DataLossError(u\'Failed to connect\',)'], + ['ERROR: Failed to create Firebase accounts', + 'Exception(u\'uh-oh!\',)'], ['SUCCESS: Created Firebase accounts', 6], ]) successful_assocs = auth_assocs[:3] + auth_assocs[6:] - self.assert_multi_auth_mappings_exist(successful_assocs) - self.sdk_stub.assert_multi_firebase_users_exist( + self.assert_firebase_assoc_exists_multi(successful_assocs) + self.firebase_sdk_stub.assert_is_user_multi( [a.auth_id for a in successful_assocs]) failed_assocs = auth_assocs[3:6] - self.assert_multi_auth_mappings_do_not_exist(failed_assocs) - self.sdk_stub.assert_multi_firebase_users_do_not_exist( + self.assert_firebase_assoc_does_not_exist_multi(failed_assocs) + self.firebase_sdk_stub.assert_is_not_user_multi( [a.auth_id for a in failed_assocs]) self.assertItemsEqual(self.run_one_off_job(), [ @@ -379,8 +365,8 @@ def test_single_import_batch_error_is_reported(self): ['SUCCESS: Created Firebase accounts', 3], ]) - self.assert_multi_auth_mappings_exist(auth_assocs) - self.sdk_stub.assert_multi_firebase_users_exist( + self.assert_firebase_assoc_exists_multi(auth_assocs) + self.firebase_sdk_stub.assert_is_user_multi( [a.auth_id for a in auth_assocs]) self.assertItemsEqual(self.run_one_off_job(), [ @@ -388,32 +374,34 @@ def test_single_import_batch_error_is_reported(self): ]) def test_individual_user_import_errors_are_reported(self): - self.exit_stack.enter_context( - self.swap(auth_jobs, 'MAX_USERS_FIREBASE_CAN_IMPORT_PER_CALL', 3)) - mock_import_users_error = self.sdk_stub.mock_import_users_error( - user_error_sequence=(False, True, False, False)) + self.exit_stack.enter_context(self.swap( + auth_jobs.PopulateFirebaseAccountsOneOffJob, + 'MAX_USERS_FIREBASE_CAN_IMPORT_PER_CALL', 3)) + mock_import_users_error = ( + self.firebase_sdk_stub.mock_import_users_error( + individual_error_pattern=[None, 'uh-oh!', None, None])) auth_assocs = self.create_multi_oppia_users(10) with mock_import_users_error: self.assertItemsEqual(self.run_one_off_job(), [ - ['FAILURE: Failed to create Firebase accounts', - 'Import user_id=\'uid_aid1\' failed: FirebaseError'], - ['FAILURE: Failed to create Firebase accounts', - 'Import user_id=\'uid_aid5\' failed: FirebaseError'], - ['FAILURE: Failed to create Firebase accounts', - 'Import user_id=\'uid_aid9\' failed: FirebaseError'], + ['ERROR: Failed to create Firebase accounts', + 'Import user_id=\'uid_aid_index_1\' failed: uh-oh!'], + ['ERROR: Failed to create Firebase accounts', + 'Import user_id=\'uid_aid_index_5\' failed: uh-oh!'], + ['ERROR: Failed to create Firebase accounts', + 'Import user_id=\'uid_aid_index_9\' failed: uh-oh!'], ['SUCCESS: Created Firebase accounts', 7], ]) successful_assocs = ( - auth_assocs[:1] + auth_assocs[2:5] + auth_assocs[6:9]) - self.assert_multi_auth_mappings_exist(successful_assocs) - self.sdk_stub.assert_multi_firebase_users_exist( + auth_assocs[0:1] + auth_assocs[2:5] + auth_assocs[6:9]) + self.assert_firebase_assoc_exists_multi(successful_assocs) + self.firebase_sdk_stub.assert_is_user_multi( [a.auth_id for a in successful_assocs]) failed_assocs = [auth_assocs[1], auth_assocs[5], auth_assocs[9]] - self.assert_multi_auth_mappings_do_not_exist(failed_assocs) - self.sdk_stub.assert_multi_firebase_users_do_not_exist( + self.assert_firebase_assoc_does_not_exist_multi(failed_assocs) + self.firebase_sdk_stub.assert_is_not_user_multi( [a.auth_id for a in failed_assocs]) self.assertItemsEqual(self.run_one_off_job(), [ @@ -421,14 +409,29 @@ def test_individual_user_import_errors_are_reported(self): ['SUCCESS: Created Firebase accounts', 3], ]) - self.assert_multi_auth_mappings_exist(auth_assocs) - self.sdk_stub.assert_multi_firebase_users_exist( + self.assert_firebase_assoc_exists_multi(auth_assocs) + self.firebase_sdk_stub.assert_is_user_multi( [a.auth_id for a in auth_assocs]) self.assertItemsEqual(self.run_one_off_job(), [ ['INFO: Pre-existing Firebase accounts', 10], ]) + def test_super_admin_is_created(self): + auth_assoc = self.create_oppia_user(email=feconf.ADMIN_EMAIL_ADDRESS) + + self.assertItemsEqual(self.run_one_off_job(), [ + ['SUCCESS: Created Firebase accounts', 1], + ['INFO: Super admin created', [auth_assoc.user_id]], + ]) + + self.assert_firebase_assoc_exists(*auth_assoc) + self.firebase_sdk_stub.assert_is_super_admin(auth_assoc.auth_id) + + self.assertItemsEqual(self.run_one_off_job(), [ + ['INFO: Pre-existing Firebase accounts', 1], + ]) + def test_system_comitter_is_ignored(self): auth_assoc = self.create_oppia_user() auth_models.UserAuthDetailsModel( @@ -442,5 +445,160 @@ def test_system_comitter_is_ignored(self): ['INFO: SYSTEM_COMMITTER_ID skipped', [auth_assoc.user_id]], ]) - self.assert_auth_mapping_does_not_exist(auth_assoc) - self.sdk_stub.assert_firebase_user_does_not_exist(auth_assoc.auth_id) + self.assert_firebase_assoc_does_not_exist(*auth_assoc) + self.firebase_sdk_stub.assert_is_not_user(auth_assoc.auth_id) + + +class SeedFirebaseOneOffJobTests(FirebaseOneOffJobTestBase): + + JOB_CLASS = auth_jobs.SeedFirebaseOneOffJob + + def run_one_off_job(self, include_seed_ack=False): + output = super(SeedFirebaseOneOffJobTests, self).run_one_off_job() + if include_seed_ack: + return output + return [o for o in output if o[0] != 'INFO: Found FirebaseSeedModel'] + + def put_firebase_seed_model(self): + """Creates the sole expected FirebaseSeedModel into storage.""" + auth_models.FirebaseSeedModel( + id=auth_models.ONLY_FIREBASE_SEED_MODEL_ID).put() + + def test_with_no_models(self): + self.assertItemsEqual(self.run_one_off_job(), []) + + def test_wipes_firebase_auth_models_of_non_admin_user(self): + self.create_user_auth_models('uid_abc', firebase_auth_id='123') + + self.assert_firebase_assoc_exists('123', 'uid_abc') + self.assertItemsEqual(self.run_one_off_job(), [ + ['SUCCESS: UserAuthDetailsModel wiped', 1], + ['SUCCESS: UserIdByFirebaseAuthIdModel wiped', 1], + ]) + self.assert_firebase_assoc_does_not_exist('123', 'uid_abc') + + def test_ignores_users_without_firebase_auth_models(self): + self.create_user_auth_models('uid_abc', gae_id='123') + + self.assert_gae_assoc_exists('123', 'uid_abc') + self.assertItemsEqual(self.run_one_off_job(), [ + ['SUCCESS: UserAuthDetailsModel already wiped', 1], + ]) + self.assert_gae_assoc_exists('123', 'uid_abc') + + def test_acknowledges_admin_email_address_without_modifying_it(self): + self.create_user_auth_models( + 'uid_abc', email=feconf.ADMIN_EMAIL_ADDRESS, firebase_auth_id='123') + self.firebase_sdk_stub.create_user( + '123', email=feconf.ADMIN_EMAIL_ADDRESS) + + self.assert_firebase_assoc_exists('123', 'uid_abc') + + self.assertItemsEqual(self.run_one_off_job(), [ + ['INFO: Found feconf.ADMIN_EMAIL_ADDRESS in UserAuthDetailsModel', + ['user_id=uid_abc']], + ['INFO: Found feconf.ADMIN_EMAIL_ADDRESS in ' + 'UserIdByFirebaseAuthIdModel', ['user_id=uid_abc']], + ]) + + self.assert_firebase_assoc_exists('123', 'uid_abc') + + def test_acknowledges_system_comitter_id_without_modifying_it(self): + self.create_user_auth_models( + 'uid_abc', firebase_auth_id='123', + gae_id=feconf.SYSTEM_COMMITTER_ID, email=feconf.ADMIN_EMAIL_ADDRESS) + self.firebase_sdk_stub.create_user( + '123', email=feconf.ADMIN_EMAIL_ADDRESS) + + self.assert_firebase_assoc_exists('123', 'uid_abc') + self.assert_gae_assoc_exists(feconf.SYSTEM_COMMITTER_ID, 'uid_abc') + + self.assertItemsEqual(self.run_one_off_job(), [ + ['INFO: Found feconf.SYSTEM_COMMITTER_ID in UserAuthDetailsModel', + ['user_id=uid_abc']], + ['INFO: Found feconf.SYSTEM_COMMITTER_ID in ' + 'UserIdByFirebaseAuthIdModel', ['user_id=uid_abc']], + ]) + + self.assert_firebase_assoc_exists('123', 'uid_abc') + + def test_acknowledges_firebase_seed_model(self): + self.put_firebase_seed_model() + + self.assertItemsEqual(self.run_one_off_job(include_seed_ack=True), [ + ['INFO: Found FirebaseSeedModel', ['1']], + ]) + + def test_deletes_many_users(self): + self.put_firebase_seed_model() + + self.exit_stack.enter_context(self.swap( + self.JOB_CLASS, 'MAX_USERS_FIREBASE_CAN_DELETE_PER_CALL', 3)) + + uids = ['aid_%d' % i for i in python_utils.RANGE(10)] + self.firebase_sdk_stub.import_users( + [firebase_admin.auth.ImportUserRecord(uid) for uid in uids]) + + self.firebase_sdk_stub.assert_is_user_multi(uids) + self.assertItemsEqual(self.run_one_off_job(), [ + ['SUCCESS: Firebase accounts deleted', 10], + ]) + self.firebase_sdk_stub.assert_is_not_user_multi(uids) + + def test_acknowledges_firebase_super_admin(self): + self.put_firebase_seed_model() + + self.firebase_sdk_stub.create_user( + '123', email=feconf.ADMIN_EMAIL_ADDRESS) + + self.assertItemsEqual(self.run_one_off_job(), [ + ['INFO: Found feconf.ADMIN_EMAIL_ADDRESS in Firebase account', + ['firebase_auth_id=123']], + ]) + + def test_reports_error_if_user_batch_failed(self): + self.put_firebase_seed_model() + + uh_oh = Exception('uh-oh!') + unlucky = Exception('unlucky') + self.exit_stack.enter_context( + self.firebase_sdk_stub.mock_delete_users_error( + batch_error_pattern=[None, uh_oh, None, unlucky])) + self.exit_stack.enter_context(self.swap( + self.JOB_CLASS, 'MAX_USERS_FIREBASE_CAN_DELETE_PER_CALL', 3)) + + uids = ['aid_%d' % i for i in python_utils.RANGE(10)] + self.firebase_sdk_stub.import_users( + [firebase_admin.auth.ImportUserRecord(uid) for uid in uids]) + + self.firebase_sdk_stub.assert_is_user_multi(uids) + self.assertItemsEqual(self.run_one_off_job(), [ + ['ERROR: Failed to delete a batch of Firebase accounts', + 'count=4, reasons=[%r, %r]' % (uh_oh, unlucky)], + ['SUCCESS: Firebase accounts deleted', 6], + ]) + self.firebase_sdk_stub.assert_is_not_user_multi(uids[:3] + uids[6:9]) + self.firebase_sdk_stub.assert_is_user_multi(uids[3:6] + uids[9:]) + + def test_reports_error_if_individual_users_failed(self): + self.put_firebase_seed_model() + + self.exit_stack.enter_context( + self.firebase_sdk_stub.mock_delete_users_error( + individual_error_pattern=[None, 'uh-oh!', None, None])) + self.exit_stack.enter_context(self.swap( + self.JOB_CLASS, 'MAX_USERS_FIREBASE_CAN_DELETE_PER_CALL', 3)) + + uids = ['aid_%d' % i for i in python_utils.RANGE(10)] + self.firebase_sdk_stub.import_users( + [firebase_admin.auth.ImportUserRecord(uid) for uid in uids]) + + self.firebase_sdk_stub.assert_is_user_multi(uids) + self.assertItemsEqual(self.run_one_off_job(), [ + ['ERROR: Failed to delete an individual Firebase account', + ['firebase_auth_id=aid_1, reason=uh-oh!', + 'firebase_auth_id=aid_5, reason=uh-oh!', + 'firebase_auth_id=aid_9, reason=uh-oh!'] + ], + ['SUCCESS: Firebase accounts deleted', 7], + ]) diff --git a/core/domain/auth_services.py b/core/domain/auth_services.py index e291f009e042..adca876702f9 100644 --- a/core/domain/auth_services.py +++ b/core/domain/auth_services.py @@ -21,6 +21,7 @@ from core.domain import auth_domain from core.platform import models +from core.platform.auth import firebase_auth_services auth_models, = models.Registry.import_models([models.NAMES.auth]) @@ -225,3 +226,28 @@ def associate_multi_auth_ids_with_user_ids(auth_id_user_id_pairs): """ platform_auth_services.associate_multi_auth_ids_with_user_ids( auth_id_user_id_pairs) + + +def grant_super_admin_privileges(user_id): + """Grants the user super admin privileges. + + Args: + user_id: str. The Oppia user ID to promote to super admin. + """ + firebase_auth_services.grant_super_admin_privileges(user_id) + + +def revoke_super_admin_privileges(user_id): + """Revokes the user's super admin privileges. + + Args: + user_id: str. The Oppia user ID to revoke privileges from. + """ + firebase_auth_services.revoke_super_admin_privileges(user_id) + + +# TODO(#11462): Delete this handler once the Firebase migration logic is +# rollback-safe and all backup data is using post-migration data. +def seed_firebase(): + """Prepares Oppia and Firebase to run the SeedFirebaseOneOffJob.""" + firebase_auth_services.seed_firebase() diff --git a/core/domain/auth_validators.py b/core/domain/auth_validators.py index 0a2d8f3ab26d..e61ab0199b24 100644 --- a/core/domain/auth_validators.py +++ b/core/domain/auth_validators.py @@ -78,6 +78,16 @@ def _get_model_id_regex(cls, unused_item): @classmethod def _get_external_id_relationships(cls, item): + """Returns a mapping of external id to model class. + + Args: + item: auth_models.UserIdentifiersModel. Entity to validate. + + Returns: + list(ExternalModelFetcherDetails). A list whose values are + ExternalModelFetcherDetails instances each representing + the class and ids for a single type of external model to fetch. + """ return [ base_model_validators.UserSettingsModelFetcherDetails( 'user_settings_ids', [item.user_id], @@ -113,6 +123,16 @@ def _get_model_id_regex(cls, unused_item): @classmethod def _get_external_id_relationships(cls, item): + """Returns a mapping of external id to model class. + + Args: + item: auth_models.UserIdByFirebaseAuthIdModel. Entity to validate. + + Returns: + list(ExternalModelFetcherDetails). A list whose values are + ExternalModelFetcherDetails instances each representing + the class and ids for a single type of external model to fetch. + """ return [ base_model_validators.UserSettingsModelFetcherDetails( 'user_settings_ids', [item.user_id], @@ -123,3 +143,35 @@ def _get_external_id_relationships(cls, item): auth_models.UserAuthDetailsModel, [item.user_id]), ] + + +class FirebaseSeedModelValidator(base_model_validators.BaseModelValidator): + """Class for validating FirebaseSeedModel.""" + + @classmethod + def _validate_model_id(cls, item): + """Checks whether the id of model matches the regex specified for + the model. + + Args: + item: datastore_services.Model. Entity to validate. + """ + if item.id != auth_models.ONLY_FIREBASE_SEED_MODEL_ID: + cls._add_error( + 'model %s' % base_model_validators.ERROR_CATEGORY_ID_CHECK, + 'Entity id %s: Entity id must be %s' % ( + item.id, auth_models.ONLY_FIREBASE_SEED_MODEL_ID)) + + @classmethod + def _get_external_id_relationships(cls, item): + """Returns a mapping of external id to model class. + + Args: + item: auth_models.FirebaseSeedModel. Entity to validate. + + Returns: + list(ExternalModelFetcherDetails). A list whose values are + ExternalModelFetcherDetails instances each representing + the class and ids for a single type of external model to fetch. + """ + return [] diff --git a/core/domain/auth_validators_test.py b/core/domain/auth_validators_test.py index e953ecb48bf0..d570ac4b5a70 100644 --- a/core/domain/auth_validators_test.py +++ b/core/domain/auth_validators_test.py @@ -459,3 +459,26 @@ def test_audit_with_missing_user_auth_details_model_fails(self): 'doesn\'t exist' % (self.auth_id, self.user_id, self.user_id)], ], ]) + + +class FirebaseSeedModelValidatorTests(AuthValidatorTestBase): + + JOB_CLASS = prod_validation_jobs_one_off.FirebaseSeedModelAuditOneOffJob + + def test_audit_with_valid_id_reports_success(self): + auth_models.FirebaseSeedModel( + id=auth_models.ONLY_FIREBASE_SEED_MODEL_ID).put() + + self.assertItemsEqual(self.run_job_and_get_output(), [ + ['fully-validated FirebaseSeedModel', 1], + ]) + + def test_audit_with_invalid_id_reports_an_error(self): + invalid_id = 'abc' + auth_models.FirebaseSeedModel(id=invalid_id).put() + + self.assertItemsEqual(self.run_job_and_get_output(), [ + ['failed validation check for model id check of FirebaseSeedModel', + ['Entity id %s: Entity id must be 1' % (invalid_id,)], + ], + ]) diff --git a/core/domain/prod_validation_jobs_one_off.py b/core/domain/prod_validation_jobs_one_off.py index 8833aa3c46e5..c0725c81a259 100644 --- a/core/domain/prod_validation_jobs_one_off.py +++ b/core/domain/prod_validation_jobs_one_off.py @@ -1001,6 +1001,14 @@ def entity_classes_to_map_over(cls): return [auth_models.UserIdByFirebaseAuthIdModel] +class FirebaseSeedModelAuditOneOffJob(ProdValidationAuditOneOffJob): + """Job that audits and validates FirebaseSeedModel.""" + + @classmethod + def entity_classes_to_map_over(cls): + return [auth_models.FirebaseSeedModel] + + class PlatformParameterModelAuditOneOffJob(ProdValidationAuditOneOffJob): """Job that audits and validates PlatformParameterModel.""" diff --git a/core/domain/user_services.py b/core/domain/user_services.py index 34cef434bb52..abb0c49e16a4 100644 --- a/core/domain/user_services.py +++ b/core/domain/user_services.py @@ -2156,13 +2156,18 @@ def log_username_change(committer_id, old_username, new_username): new_username=new_username).put() -def create_login_url(target_url): +def create_login_url(return_url): """Creates a login url. Args: - target_url: str. The URL to redirect to after login. + return_url: str. The URL to redirect to after login. Returns: str. The correct login URL that includes the page to redirect to. """ - return current_user_services.create_login_url(target_url) + # TODO(#11462): Delete this function. Pre-#11462, we needed this because we + # didn't control the page or URL responsible for user authentication. + # This is no longer the case. We've implemented our own user authentication + # flow on top of the Firebase SDK in "core/templates/pages/login-page", and + # this function will always redirect to its static location ("/login"). + return '/login?%s' % python_utils.url_encode({'return_url': return_url}) diff --git a/core/jobs_registry.py b/core/jobs_registry.py index 3c2e87592191..ae89e021896c 100644 --- a/core/jobs_registry.py +++ b/core/jobs_registry.py @@ -53,7 +53,7 @@ activity_jobs_one_off.ValidateSnapshotMetadataModelsJob, activity_jobs_one_off.SnapshotMetadataCommitMsgAuditOneOffJob, activity_jobs_one_off.SnapshotMetadataCommitMsgShrinkOneOffJob, - auth_jobs_one_off.AuditFirebaseImportReadinessOneOffJob, + auth_jobs_one_off.SeedFirebaseOneOffJob, auth_jobs_one_off.PopulateFirebaseAccountsOneOffJob, collection_jobs_one_off.CollectionMigrationOneOffJob, collection_jobs_one_off.RemoveCollectionRightsTranslatorIdsOneOffJob, @@ -278,6 +278,7 @@ prod_validation_jobs_one_off.UserContributionsModelAuditOneOffJob, prod_validation_jobs_one_off.UserEmailPreferencesModelAuditOneOffJob, prod_validation_jobs_one_off.UserIdByFirebaseAuthIdModelAuditOneOffJob, + prod_validation_jobs_one_off.FirebaseSeedModelAuditOneOffJob, prod_validation_jobs_one_off.UserNormalizedNameAuditOneOffJob, prod_validation_jobs_one_off.UserQueryModelAuditOneOffJob, prod_validation_jobs_one_off.UserRecentChangesBatchModelAuditOneOffJob, diff --git a/core/platform/auth/firebase_auth_services.py b/core/platform/auth/firebase_auth_services.py index e87d2e0b54c3..18cea427aa24 100644 --- a/core/platform/auth/firebase_auth_services.py +++ b/core/platform/auth/firebase_auth_services.py @@ -54,7 +54,6 @@ from __future__ import absolute_import # pylint: disable=import-only-modules from __future__ import unicode_literals # pylint: disable=import-only-modules -import contextlib import logging from constants import constants @@ -67,11 +66,36 @@ from firebase_admin import auth as firebase_auth from firebase_admin import exceptions as firebase_exceptions -auth_models, = models.Registry.import_models([models.NAMES.auth]) +auth_models, user_models = ( + models.Registry.import_models([models.NAMES.auth, models.NAMES.user])) transaction_services = models.Registry.import_transaction_services() +def establish_firebase_connection(): + """Establishes the connection to Firebase needed by the rest of the SDK. + + All Firebase operations require an "app", the abstraction used for a + Firebase server connection. The initialize_app() function raises an error + when it's called more than once, however, so we make this function + idempotent by trying to "get" the app first. + + Returns: + firebase_admin.App. The App being by the Firebase SDK. + + Raises: + Exception. The Firebase app has a genuine problem. + """ + try: + firebase_admin.get_app() + except ValueError as error: + if 'initialize_app' in python_utils.UNICODE(error): + firebase_admin.initialize_app( + options={'projectId': feconf.OPPIA_PROJECT_ID}) + else: + raise + + def establish_auth_session(request, response): """Sets login cookies to maintain a user's sign-in session. @@ -89,9 +113,8 @@ def establish_auth_session(request, response): if cookie_claims is not None: return - with _firebase_admin_context(): - fresh_cookie = firebase_auth.create_session_cookie( - _get_id_token(request), feconf.FIREBASE_SESSION_COOKIE_MAX_AGE) + fresh_cookie = firebase_auth.create_session_cookie( + _get_id_token(request), feconf.FIREBASE_SESSION_COOKIE_MAX_AGE) response.set_cookie( feconf.FIREBASE_SESSION_COOKIE_NAME, @@ -100,7 +123,7 @@ def establish_auth_session(request, response): overwrite=True, # Toggles https vs http. The production server uses https, but the local # developement server uses http. - secure=(not constants.DEV_MODE), + secure=(not constants.EMULATOR_MODE), # Using the HttpOnly flag when generating a cookie helps mitigate the # risk of client side script accessing the protected cookie (if the # browser supports it). @@ -158,10 +181,13 @@ def mark_user_for_deletion(user_id): assoc_by_auth_id_model.deleted = True assoc_by_auth_id_model.update_timestamps() assoc_by_auth_id_model.put() + else: + logging.error( + '[WIPEOUT] User with user_id=%s has no Firebase account' % user_id) + return try: - with _firebase_admin_context(): - firebase_auth.update_user(assoc_by_auth_id_model.id, disabled=True) + firebase_auth.update_user(assoc_by_auth_id_model.id, disabled=True) except (firebase_exceptions.FirebaseError, ValueError): # NOTE: logging.exception appends the stack trace automatically. The # errors are not re-raised because wipeout_services, the user of this @@ -182,8 +208,9 @@ def delete_external_auth_associations(user_id): if auth_id is None: return try: - with _firebase_admin_context(): - firebase_auth.delete_user(auth_id) + firebase_auth.delete_user(auth_id) + except firebase_auth.UserNotFoundError: + logging.exception('[WIPEOUT] Firebase account already deleted') except (firebase_exceptions.FirebaseError, ValueError): # NOTE: logging.exception appends the stack trace automatically. The # errors are not re-raised because wipeout_services, the user of this @@ -208,8 +235,7 @@ def verify_external_auth_associations_are_deleted(user_id): if auth_id is None: return True try: - with _firebase_admin_context(): - firebase_auth.get_user(auth_id) + firebase_auth.get_user(auth_id) except firebase_auth.UserNotFoundError: return True except (firebase_exceptions.FirebaseError, ValueError): @@ -393,24 +419,103 @@ def associate_multi_auth_ids_with_user_ids(auth_id_user_id_pairs): auth_models.UserAuthDetailsModel.put_multi(assoc_by_user_id_models) -@contextlib.contextmanager -def _firebase_admin_context(): - """Returns a context for calling the Firebase Admin SDK. +def grant_super_admin_privileges(user_id): + """Grants the user super admin privileges. - Yields: - None. No relevent context expression. + Args: + user_id: str. The Oppia user ID to promote to super admin. + """ + auth_id = get_auth_id_from_user_id(user_id) + if auth_id is None: + raise ValueError('user_id=%s has no Firebase account' % user_id) + firebase_admin.auth.set_custom_user_claims( + auth_id, '{"role":"%s"}' % feconf.FIREBASE_ROLE_SUPER_ADMIN) + + +def revoke_super_admin_privileges(user_id): + """Revokes the user's super admin privileges. + + Args: + user_id: str. The Oppia user ID to revoke privileges from. """ - # NOTE: "app" is the term Firebase uses for the "entry point" to the - # Firebase SDK. Oppia only has one server, so it only needs to instantiate - # one app. - firebase_connection = firebase_admin.initialize_app( - options={'projectId': feconf.OPPIA_PROJECT_ID}) + auth_id = get_auth_id_from_user_id(user_id) + if auth_id is None: + raise ValueError('user_id=%s has no Firebase account' % user_id) + firebase_admin.auth.set_custom_user_claims(auth_id, None) + + +def seed_firebase(): + """Prepares Oppia and Firebase to run the SeedFirebaseOneOffJob. + + NOTE: This function is idempotent. + + TODO(#11462): Delete this handler once the Firebase migration logic is + rollback-safe and all backup data is using post-migration data. + """ + seed_model = auth_models.FirebaseSeedModel.get( + auth_models.ONLY_FIREBASE_SEED_MODEL_ID, strict=False) + if seed_model is None: # Exactly 1 seed model must exist. + auth_models.FirebaseSeedModel( + id=auth_models.ONLY_FIREBASE_SEED_MODEL_ID).put() + + user_ids_with_admin_email = [ + key.id() for key in user_models.UserSettingsModel.query( + user_models.UserSettingsModel.email == feconf.ADMIN_EMAIL_ADDRESS + ).iter(keys_only=True) + ] + assoc_by_user_id_models = [ + model for model in auth_models.UserAuthDetailsModel.get_multi( + user_ids_with_admin_email) + if model is not None and model.gae_id != feconf.SYSTEM_COMMITTER_ID + ] + if len(assoc_by_user_id_models) != 1: + raise Exception( + '%s must correspond to exactly 1 user (excluding user_id=%s), but ' + 'found user_ids=[%s]' % ( + feconf.ADMIN_EMAIL_ADDRESS, feconf.SYSTEM_COMMITTER_ID, + ', '.join(m.id for m in assoc_by_user_id_models))) + else: + assoc_by_user_id_model = assoc_by_user_id_models[0] + user_id = assoc_by_user_id_model.id + + auth_id = assoc_by_user_id_model.firebase_auth_id + if auth_id is None: + auth_id = user_id[4:] if user_id.startswith('uid_') else user_id + assoc_by_user_id_model.firebase_auth_id = auth_id + assoc_by_user_id_model.update_timestamps(update_last_updated_time=False) + assoc_by_user_id_model.put() + + assoc_by_auth_id_model = ( + auth_models.UserIdByFirebaseAuthIdModel.get(auth_id, strict=False)) + if assoc_by_auth_id_model is None: + auth_models.UserIdByFirebaseAuthIdModel( + id=auth_id, user_id=user_id).put() + elif assoc_by_auth_id_model.user_id != user_id: + assoc_by_auth_id_model.user_id = user_id + assoc_by_auth_id_model.update_timestamps(update_last_updated_time=False) + assoc_by_auth_id_model.put() + + custom_claims = '{"role":"%s"}' % feconf.FIREBASE_ROLE_SUPER_ADMIN + try: - yield - finally: - # NOTE: This is not dangerous. We are just deleting the resources used - # to form a connection to Firebase servers. - firebase_admin.delete_app(firebase_connection) + user = firebase_admin.auth.get_user_by_email(feconf.ADMIN_EMAIL_ADDRESS) + except firebase_admin.auth.UserNotFoundError: + create_new_firebase_account = True + else: + if user.uid != auth_id: + firebase_admin.auth.update_user(user.uid, disabled=True) + firebase_admin.auth.delete_user(user.uid) + create_new_firebase_account = True + else: + firebase_admin.auth.set_custom_user_claims(user.uid, custom_claims) + create_new_firebase_account = False + + if create_new_firebase_account: + firebase_admin.auth.import_users([ + firebase_admin.auth.ImportUserRecord( + auth_id, email=feconf.ADMIN_EMAIL_ADDRESS, + custom_claims=custom_claims) + ]) def _get_session_cookie(request): @@ -471,9 +576,8 @@ def _get_auth_claims_from_session_cookie(cookie): # isn't authenticated. if cookie: try: - with _firebase_admin_context(): - return _create_auth_claims( - firebase_auth.verify_session_cookie(cookie)) + return _create_auth_claims( + firebase_auth.verify_session_cookie(cookie)) # NOTE: Session cookies only provide temporary authentication, so they # are expected to become obsolete over time. The following errors are # situations where this can happen. @@ -498,6 +602,7 @@ def _create_auth_claims(firebase_claims): auth_id = firebase_claims.get('sub') email = firebase_claims.get('email') role_is_super_admin = ( + email == feconf.ADMIN_EMAIL_ADDRESS or firebase_claims.get('role') == feconf.FIREBASE_ROLE_SUPER_ADMIN) return auth_domain.AuthClaims( auth_id, email, role_is_super_admin=role_is_super_admin) diff --git a/core/platform/auth/firebase_auth_services_test.py b/core/platform/auth/firebase_auth_services_test.py index fd764c5f94bc..f28f3273505b 100644 --- a/core/platform/auth/firebase_auth_services_test.py +++ b/core/platform/auth/firebase_auth_services_test.py @@ -32,13 +32,16 @@ from core.tests import test_utils import feconf import python_utils +import utils import contextlib2 import firebase_admin from firebase_admin import exceptions as firebase_exceptions +import mock import webapp2 -auth_models, = models.Registry.import_models([models.NAMES.auth]) +auth_models, user_models = ( + models.Registry.import_models([models.NAMES.auth, models.NAMES.user])) class FirebaseAdminSdkStub(python_utils.OBJECT): @@ -73,17 +76,28 @@ def test_sdk(self): self.assertEqual(user_record.uid, 'uid') """ + _IMPLEMENTED_SDK_FUNCTION_NAMES = [ + 'create_session_cookie', + 'create_user', + 'delete_user', + 'delete_users', + 'get_user', + 'get_user_by_email', + 'import_users', + 'list_users', + 'set_custom_user_claims', + 'update_user', + 'verify_id_token', + 'verify_session_cookie', + ] + _UNIMPLEMENTED_SDK_FUNCTION_NAMES = [ 'create_custom_token', - 'create_user', 'generate_email_verification_link', 'generate_password_reset_link', 'generate_sign_in_with_email_link', - 'get_user_by_email', 'get_user_by_phone_number', - 'list_users', 'revoke_refresh_tokens', - 'set_custom_user_claims', ] def __init__(self): @@ -93,65 +107,62 @@ def __init__(self): self._test = None def install(self, test): - """Installs a new instance of the stub on the given test class. - - The stub will emulate an initially-empty Firebase authentication server. + """Installs the stub on the given test instance. Idempotent. Args: test: test_utils.TestBase. The test to install the stub on. """ + self.uninstall() + self._test = test with contextlib2.ExitStack() as swap_stack: - swap_stack.enter_context(test.swap_to_always_return( - firebase_admin, 'initialize_app', value=object())) - swap_stack.enter_context(test.swap_to_always_return( - firebase_admin, 'delete_app')) - swap_stack.enter_context(test.swap( - firebase_admin.auth, - 'create_session_cookie', self._create_session_cookie)) - swap_stack.enter_context(test.swap( - firebase_admin.auth, - 'verify_session_cookie', self._verify_session_cookie)) - swap_stack.enter_context(test.swap( - firebase_admin.auth, 'import_users', self._import_users)) - swap_stack.enter_context(test.swap( - firebase_admin.auth, 'verify_id_token', self._verify_id_token)) - swap_stack.enter_context(test.swap( - firebase_admin.auth, 'get_user', self._get_user)) - swap_stack.enter_context(test.swap( - firebase_admin.auth, 'delete_user', self._delete_user)) - swap_stack.enter_context(test.swap( - firebase_admin.auth, 'update_user', self._update_user)) - - for function_name in self._UNIMPLEMENTED_SDK_FUNCTION_NAMES: + for name in self._IMPLEMENTED_SDK_FUNCTION_NAMES: + swap_stack.enter_context( + test.swap(firebase_admin.auth, name, getattr(self, name))) + + for name in self._UNIMPLEMENTED_SDK_FUNCTION_NAMES: swap_stack.enter_context(test.swap_to_always_raise( - firebase_admin.auth, function_name, NotImplementedError)) - - # Standard usage of ExitStack: enter a bunch of context managers - # from the safety of an ExitStack's context. Once they've all been - # opened, pop_all() of them off of the original context so they can - # *stay* open. Calling the function returned will exit all of them - # in reverse order. - # https://docs.python.org/3/library/contextlib.html#cleaning-up-in-an-enter-implementation + firebase_admin.auth, name, NotImplementedError)) + + # Allows us to exit the current context manager without closing the + # entered contexts. They will be exited later by the uninstall() + # method. self._swap_stack = swap_stack.pop_all() def uninstall(self): - """Uninstalls the stub.""" + """Uninstalls the stub. Idempotent.""" if self._swap_stack: self._swap_stack.close() self._swap_stack = None - def create_user( - self, uid, email=None, disabled=False, role_is_super_admin=False): + def create_session_cookie(self, id_token, max_age): + """Creates a new session cookie which expires after given duration. + + Args: + id_token: str. The ID Token to generate the cookie from. + max_age: datetime.timedelta. The duration the cookie remains valid. + + Returns: + str. A session cookie that can validate the user. + """ + if not id_token: + raise firebase_admin.auth.InvalidIdTokenError('missing id_token') + if max_age > datetime.timedelta(days=0): + self._session_cookie_duration_by_id_token[id_token] = max_age + # NOTE: Session cookies are often completely different, in terms of + # encoding and security, to ID Tokens. Regardless, for the purposes of + # this stub, we treat them the same. + session_cookie = id_token + return session_cookie + + def create_user(self, uid, email=None, disabled=False): """Adds user to storage if new, otherwise raises an error. Args: uid: str. The unique Firebase account ID for the user. email: str|None. The email address for the user, or None. disabled: bool. Whether the user account is to be disabled. - role_is_super_admin: bool. Whether the user account has super admin - privilages. Returns: str. An ID token that represents the given user's authorization. @@ -161,73 +172,231 @@ def create_user( UidAlreadyExistsError. The uid has already been assigned to a user. """ if uid in self._users_by_uid: - raise ValueError('uid=%r already exists' % uid) - custom_claims = ( - {'role': feconf.FIREBASE_ROLE_SUPER_ADMIN} - if role_is_super_admin else None) - self._set_user_fragile(uid, email, disabled, custom_claims) - return self._encode_user_claims(self._get_user(uid)) + raise firebase_admin.auth.UidAlreadyExistsError( + 'uid=%r already exists' % uid, None, None) + self._set_user_fragile(uid, email, disabled, None) + return self._encode_user_claims(self.get_user(uid)) - def mock_initialize_app_error(self): - """Returns a context in which `initialize_app` raises an exception.""" - return self._test.swap_to_always_raise( - firebase_admin, 'initialize_app', - error=firebase_exceptions.UnknownError('could not init')) + def delete_user(self, uid): + """Removes user from storage if found, otherwise raises an error. - def mock_delete_app_error(self): - """Returns a context in which `delete_app` raises an exception.""" - return self._test.swap_to_always_raise( - firebase_admin, 'delete_app', - error=firebase_exceptions.UnknownError('could not delete app')) + Args: + uid: str. The Firebase account ID of the user. - def mock_import_users_error( - self, call_error_sequence=(False,), user_error_sequence=(False,)): - """Returns a context in which `import_users` raises an exception. + Raises: + UserNotFoundError. The Firebase account has not been created yet. + """ + if uid not in self._users_by_uid: + raise firebase_admin.auth.UserNotFoundError('%s not found' % uid) + del self._users_by_uid[uid] - Example: - with mock_import_users_error(call_error_sequence=(False, True)): - import_users() # OK - import_users() # Raises! - import_users() # OK - import_users() # Raises! - import_users() # OK + def delete_users(self, uids, force_delete=False): + """Deletes the users identified by the specified user ids. + + Deleting a non-existing user does not generate an error (the method is + idempotent). Non-existing users are considered to be successfully + deleted and are therefore not reported as errors. + + A maximum of 1000 identifiers may be supplied. If more than 1000 + identifiers are supplied, this method raises a `ValueError`. Args: - call_error_sequence: tuple(bool). Enumerates which successive calls - will raise an exception. The pattern is cycled. - user_error_sequence: tuple(bool). Enumerates which individual users - will cause an error. The pattern is cycled. + uids: A list of strings indicating the uids of the users to be + deleted. Must have <= 1000 entries. + force_delete: Optional parameter that indicates if users should be + deleted, even if they're not disabled. Defaults to False. Returns: - Context manager. The context manager with the mocked implementation. + BatchDeleteAccountsResponse. Holds the errors encountered, if any. + + Raises: + ValueError. If any of the identifiers are invalid or if more than + 1000 identifiers are specified. """ - call_error_sequence = itertools.cycle(call_error_sequence) - user_error_sequence = itertools.cycle(user_error_sequence) - def mock_import_users(user_records): - """Mock function that fails according to the given input values.""" - if python_utils.NEXT(call_error_sequence): - raise firebase_exceptions.DataLossError('Failed to connect') - - total_records = len(user_records) - - kept_records, error_indices = [], [] - for i, (record, error) in enumerate( - python_utils.ZIP(user_records, user_error_sequence)): - if error: - error_indices.append(i) - else: - kept_records.append(record) - - if kept_records: - self._import_users(kept_records) + if len(uids) > 1000: + raise ValueError('`uids` paramter must have <= 1000 entries.') + + if force_delete: + uids_to_delete = set(uids) + errors = [] + else: + disabled_uids, enabled_uids = utils.partition( + uids, predicate=lambda uid: self._users_by_uid[uid].disabled, + enumerated=True) + uids_to_delete = {uid for _, uid in disabled_uids} + errors = [(i, 'uid=%r must be disabled first' % uid) + for i, uid in enabled_uids] + + for uid in uids_to_delete.intersection(self._users_by_uid): + del self._users_by_uid[uid] + return self._create_delete_users_result_fragile(errors) + + def get_user(self, uid): + """Returns user with given ID if found, otherwise raises an error. - return self._create_user_import_result_fragile( - total_records, error_indices=error_indices) + Args: + uid: str. The Firebase account ID of the user. - return self._test.swap( - firebase_admin.auth, 'import_users', mock_import_users) + Returns: + UserRecord. The UserRecord object of the user. + + Raises: + UserNotFoundError. The Firebase account has not been created yet. + """ + if uid not in self._users_by_uid: + raise firebase_admin.auth.UserNotFoundError('%s not found' % uid) + return self._users_by_uid[uid] + + def get_user_by_email(self, email): + """Returns user with given email if found, otherwise raises an error. + + Args: + email: str. The email address of the user. + + Returns: + UserRecord. The UserRecord object of the user. + + Raises: + UserNotFoundError. The Firebase account has not been created yet. + """ + matches = (u for u in self._users_by_uid.values() if u.email == email) + user = python_utils.NEXT(matches, None) + if user is None: + raise firebase_admin.auth.UserNotFoundError('%s not found' % email) + return user + + def import_users(self, records): + """Adds the given user records to the stub's storage. + + Args: + records: list(firebase_admin.auth.ImportUserRecord). The users to + add. + + Returns: + firebase_admin.auth.UserImportResult. Object with details about the + operation. + """ + for record in records: + self._set_user_fragile( + record.uid, record.email, record.disabled, record.custom_claims) + return self._create_user_import_result_fragile(len(records), []) + + def list_users(self, page_token=None, max_results=1000): + """Retrieves a page of user accounts from a Firebase project. + + The `page_token` argument governs the starting point of the page. The + `max_results` argument governs the maximum number of user accounts that + may be included in the returned page. This function never returns None. + If there are no user accounts in the Firebase project, this returns an + empty page. + + Args: + page_token: str|None. A non-empty page token string, which indicates + the starting point of the page (optional). Defaults to `None`, + which will retrieve the first page of users. + max_results: int|None. A positive integer indicating the maximum + number of users to include in the returned page (optional). + Defaults to 1000, which is also the maximum number allowed. + + Returns: + ListUsersPage. A ListUsersPage instance. + + Raises: + ValueError. If max_results or page_token are invalid. + FirebaseError. If an error occurs while retrieving the user + accounts. + """ + if max_results > 1000: + raise ValueError('max_results=%r must be <= 1000' % max_results) + + # NOTE: This is only sorted to make unit testing easier. + all_users = sorted(self._users_by_uid.values(), key=lambda u: u.uid) + page_list = [ + [user for user in user_group if user is not None] + for user_group in utils.grouper(all_users, max_results) + ] + + if not page_list: + return self._create_list_users_page_fragile([], 0) + + try: + page_index = int(page_token) if page_token is not None else 0 + except (ValueError, TypeError): + raise ValueError('page_token=%r is invalid' % page_token) + + if 0 <= page_index < len(page_list): + return self._create_list_users_page_fragile(page_list, page_index) + else: + raise ValueError('page_token=%r is invalid' % page_token) + + def set_custom_user_claims(self, uid, custom_claims): + """Updates the custom claims of the given user. + + Args: + uid: str. The Firebase account ID of the user. + custom_claims: str|None. A string-encoded JSON with string keys and + values, e.g. '{"role":"admin"}', or None. + + Returns: + str. The uid of the user. + + Raises: + UserNotFoundError. The Firebase account has not been created yet. + """ + return self.update_user(uid, custom_claims=custom_claims) + + def update_user(self, uid, email=None, disabled=False, custom_claims=None): + """Updates the user in storage if found, otherwise raises an error. + + Args: + uid: str. The Firebase account ID of the user. + email: str|None. The email address for the user, or None. + disabled: bool. Whether the user account is to be disabled. + custom_claims: str|None. A string-encoded JSON with string keys and + values, e.g. '{"role":"admin"}', or None. + + Returns: + str. The uid of the user. - def assert_firebase_user_exists(self, uid): + Raises: + UserNotFoundError. The Firebase account has not been created yet. + """ + if uid not in self._users_by_uid: + raise firebase_admin.auth.UserNotFoundError('%s not found' % uid) + self._set_user_fragile(uid, email, disabled, custom_claims) + return uid + + def verify_id_token(self, token): + """Returns claims for the corresponding user if the ID token is valid. + + Args: + token: str. The ID token. + + Returns: + dict(str: *). Claims for the user corresponding to the ID token. + """ + return self._decode_user_claims(token) + + def verify_session_cookie(self, session_cookie): + """Returns claims for the corresponding user if the cookie is valid. + + Args: + session_cookie: str. The session cookie. + + Returns: + dict(str: *). Claims for the user corresponding to the session + cookie. + """ + # NOTE: Session cookies are often completely different, in terms of + # encoding and security, to ID Tokens. Regardless, for the purposes of + # this stub, we treat them the same. + id_token = session_cookie + if id_token not in self._session_cookie_duration_by_id_token: + raise firebase_admin.auth.ExpiredSessionCookieError( + 'The provided Firebase session cookie is invalid', None) + return self._decode_user_claims(id_token) + + def assert_is_user(self, uid): """Asserts that an account with the given id exists. NOTE: This method can only be called after the instance has been @@ -240,7 +409,7 @@ def assert_firebase_user_exists(self, uid): uid, self._users_by_uid, msg='Firebase account not found: uid=%r' % uid) - def assert_firebase_user_does_not_exist(self, uid): + def assert_is_not_user(self, uid): """Asserts that an account with the given id does not exist. NOTE: This method can only be called after the instance has been @@ -253,7 +422,35 @@ def assert_firebase_user_does_not_exist(self, uid): uid, self._users_by_uid, msg='Unexpected Firebase account exists: uid=%r' % uid) - def assert_multi_firebase_users_exist(self, uids): + def assert_is_super_admin(self, uid): + """Asserts that the given ID has super admin privileges. + + NOTE: This method can only be called after the instance has been + installed to a test case! + + Args: + uid: str. The ID of the user to confirm. + """ + self.assert_is_user(uid) + custom_claims = self.get_user(uid).custom_claims or {} + self._test.assertEqual( + custom_claims.get('role', None), feconf.FIREBASE_ROLE_SUPER_ADMIN) + + def assert_is_not_super_admin(self, uid): + """Asserts that the given ID does not have super admin privileges. + + NOTE: This method can only be called after the instance has been + installed to a test case! + + Args: + uid: str. The ID of the user to confirm. + """ + self.assert_is_user(uid) + custom_claims = self.get_user(uid).custom_claims or {} + self._test.assertNotEqual( + custom_claims.get('role', None), feconf.FIREBASE_ROLE_SUPER_ADMIN) + + def assert_is_user_multi(self, uids): """Asserts that every account with the given ids exist. NOTE: This method can only be called after the instance has been @@ -267,7 +464,7 @@ def assert_multi_firebase_users_exist(self, uids): not_found, [], msg='Firebase accounts not found: uids=%r' % (not_found,)) - def assert_multi_firebase_users_do_not_exist(self, uids): + def assert_is_not_user_multi(self, uids): """Asserts that every account with the given ids do not exist. NOTE: This method can only be called after the instance has been @@ -281,95 +478,134 @@ def assert_multi_firebase_users_do_not_exist(self, uids): found, [], msg='Unexpected Firebase accounts exists: uids=%r' % (found,)) - def _import_users(self, user_records): - """Adds the given user records to the stub's storage. + def mock_delete_users_error( + self, + batch_error_pattern=(None,), individual_error_pattern=(None,)): + """Returns a context in which `delete_users` fails according to the + given patterns. + + Example: + with mock_delete_users_error(batch_error_pattern=(None, Exception)): + delete_users(...) # OK. + delete_users(...) # Raises Exception. + delete_users(...) # OK. + delete_users(...) # Raises Exception. + delete_users(...) # OK. Args: - user_records: list(firebase_admin.auth.ImportUserRecord). The users - to add. + batch_error_pattern: tuple(Exception|None). Enumerates which + successive calls will raise an exception. For values of None, no + exception is raised. The pattern is cycled. By default, an error + will never be raised. + individual_error_pattern: tuple(bool). Enumerates which individual + users will cause an error. The pattern is cycled. By default, an + error will never be raised. Returns: - firebase_admin.auth.UserImportResult. Object with details about the - operation. + Context manager. The context manager with the mocked implementation. """ - for record in user_records: - self._set_user_fragile( - record.uid, record.email, record.disabled, record.custom_claims) - return self._create_user_import_result_fragile(len(user_records)) + batch_error_pattern = itertools.cycle(batch_error_pattern) + individual_error_pattern = itertools.cycle(individual_error_pattern) - def _create_session_cookie(self, id_token, max_age): - """Creates a new session cookie which expires after given duration. + def mock_delete_users(uids, force_delete=False): + """Mock function that fails according to the input patterns.""" + error_to_raise = python_utils.NEXT(batch_error_pattern) + if error_to_raise is not None: + raise error_to_raise - Args: - id_token: str. The ID Token to generate the cookie from. - max_age: datetime.timedelta. The duration the cookie remains valid. + uids_to_delete, uids_to_fail = utils.partition( + python_utils.ZIP(uids, individual_error_pattern), + predicate=lambda uid_and_error: uid_and_error[1] is None, + enumerated=True) - Returns: - str. A session cookie that can validate the user. - """ - if not id_token: - raise firebase_admin.auth.InvalidIdTokenError('missing id_token') - if max_age > datetime.timedelta(days=0): - self._session_cookie_duration_by_id_token[id_token] = max_age - # NOTE: Session cookies are often completely different, in terms of - # encoding and security, to ID Tokens. Regardless, for the purposes of - # this stub, we treat them the same. - session_cookie = id_token - return session_cookie + uids_to_delete = [uid for _, (uid, _) in uids_to_delete] + errors = [(i, error) for i, (_, error) in uids_to_fail] - def _verify_session_cookie(self, session_cookie): - """Returns claims for the corresponding user if the cookie is valid. + self.delete_users(uids_to_delete, force_delete=force_delete) + + return self._create_delete_users_result_fragile(errors) + + return self._test.swap( + firebase_admin.auth, 'delete_users', mock_delete_users) + + def mock_import_users_error( + self, + batch_error_pattern=(None,), individual_error_pattern=(None,)): + """Returns a context in which `import_users` fails according to the + given patterns. + + Example: + with mock_import_users_error(batch_error_pattern=(False, True)): + import_users(...) # OK + import_users(...) # Raises! + import_users(...) # OK + import_users(...) # Raises! + import_users(...) # OK Args: - session_cookie: str. The session cookie. + batch_error_pattern: tuple(Exception|None). Enumerates which + successive calls will raise an exception. For values of None, no + exception is raised. The pattern is cycled. By default, an error + will never be raised. + individual_error_pattern: tuple(str|None). Enumerates which + individual users will cause an error. Each value is either the + error reason (a string), or None. The pattern is cycled. By + default, an error will never be raised. Returns: - dict(str: *). Claims for the user corresponding to the session - cookie. + Context manager. The context manager with the mocked implementation. """ - # NOTE: Session cookies are often completely different, in terms of - # encoding and security, to ID Tokens. Regardless, for the purposes of - # this stub, we treat them the same. - id_token = session_cookie - if id_token not in self._session_cookie_duration_by_id_token: - raise firebase_admin.auth.ExpiredSessionCookieError( - 'The provided Firebase session cookie is invalid', None) - return self._decode_user_claims(id_token) + batch_error_pattern = itertools.cycle(batch_error_pattern) + individual_error_pattern = itertools.cycle(individual_error_pattern) - def _verify_id_token(self, token): - """Returns claims for the corresponding user if the ID token is valid. + def mock_import_users(records): + """Mock function that fails according to the input patterns.""" + error_to_raise = python_utils.NEXT(batch_error_pattern) + if error_to_raise is not None: + raise error_to_raise + + records_to_import, records_to_fail = utils.partition( + python_utils.ZIP(records, individual_error_pattern), + predicate=lambda record_and_error: record_and_error[1] is None, + enumerated=True) + + self.import_users([record for _, (record, _) in records_to_import]) + + return self._create_user_import_result_fragile( + len(records), [(i, error) for i, (_, error) in records_to_fail]) + + return self._test.swap( + firebase_admin.auth, 'import_users', mock_import_users) + + def _encode_user_claims(self, user): + """Returns encoded claims for the given user. Args: - token: str. The ID token. + user: firebase_admin.auth.UserRecord. The source of the claims. Returns: - dict(str: *). Claims for the user corresponding to the ID token. + str. An encoded representation of the user's claims. """ - return self._decode_user_claims(token) - - def _create_user_import_result_fragile(self, total, error_indices=None): - """Creates a new UserImportResult instance with the given values. + claims = {'sub': user.uid} + if user.email: + claims['email'] = user.email + if user.custom_claims: + claims.update(user.custom_claims) + return json.dumps(claims) - FRAGILE! The dict keys used by the UserImportResult constructor are an - implementation detail that may break in future versions of the SDK. + def _decode_user_claims(self, encoded_claims): + """Returns the given decoded claims. Args: - total: int. The total number of records initially requested. - error_indices: list(int)|None. The indicies of the records which - have failed. If None, then the result will not contain any - errors. + encoded_claims: str. The encoded claims. Returns: - firebase_admin.auth.UserImportResult. A UserImportResult with the - given results. + dict(str: *). The decoded claims. """ - if error_indices is None: - error_indices = [] - return firebase_admin.auth.UserImportResult({ - 'error': [ - {'index': i, 'message': 'FirebaseError'} for i in error_indices - ], - }, total) + try: + return json.loads(encoded_claims) + except ValueError: + return None def _set_user_fragile(self, uid, email, disabled, custom_claims): """Sets the given properties for the corresponding user. @@ -389,85 +625,131 @@ def _set_user_fragile(self, uid, email, disabled, custom_claims): 'customAttributes': custom_claims, }) - def _get_user(self, uid): - """Returns the user with given ID if found, otherwise raises an error. + def _create_list_users_page_fragile(self, page_list, page_index): + """Creates a new ListUsersPage mock. + + FRAGILE! The mock is not from the real SDK, so it's vulnerable to + becoming out-of-sync with the interface of the real ListUsersPage. Args: - uid: str. The Firebase account ID of the user. + page_list: list(list(UserRecord)). The pages of users. + page_index: int. The starting index of the page. Returns: - UserRecord. The UserRecord object of the user. - - Raises: - UserNotFoundError. The Firebase account has not been created yet. + Mock. A mock implementation of ListUsersPage. """ - if uid not in self._users_by_uid: - raise firebase_admin.auth.UserNotFoundError('%s not found' % uid) - return self._users_by_uid[uid] - - def _update_user(self, uid, email=None, disabled=False, custom_claims=None): - """Updates the user in storage if found, otherwise raises an error. + page = mock.Mock() + if page_index < len(page_list): + page.users = page_list[page_index] + page.has_next_page = (page_index + 1) < len(page_list) + page.next_page_token = ( + '' if not page.has_next_page else + python_utils.UNICODE(page_index + 1)) + page.get_next_page = lambda: ( + None if not page.has_next_page else + self._create_list_users_page_fragile(page_list, page_index + 1)) + page.iterate_all = lambda: ( + itertools.chain.from_iterable(page_list[page_index:])) + else: + page.users = [] + page.has_next_page = False + page.next_page_token = '' + page.get_next_page = lambda: None + page.iterate_all = lambda: iter([]) + return page + + def _create_delete_users_result_fragile(self, errors): + """Creates a new BatchDeleteAccountsResponse instance with the given + values. + + FRAGILE! The dict keys used by the BatchDeleteAccountsResponse + constructor are an implementation detail that may break in future + versions of the SDK. Args: - uid: str. The Firebase account ID of the user. - email: str|None. The email address for the user, or None. - disabled: bool. Whether the user account is to be disabled. - custom_claims: str|None. A string-encoded JSON with string keys and - values, e.g. '{"role":"admin"}', or None. + errors: list(tuple(int, str)). A list of (index, error) pairs. Returns: - str. The uid of the user. - - Raises: - UserNotFoundError. The Firebase account has not been created yet. + firebase_admin.auth.BatchDeleteAccountsResponse. The response. """ - if uid not in self._users_by_uid: - raise firebase_admin.auth.UserNotFoundError('%s not found' % uid) - self._set_user_fragile(uid, email, disabled, custom_claims) - return uid + return firebase_admin.auth.BatchDeleteAccountsResponse( + errors=[{'index': i, 'message': error} for i, error in errors]) - def _delete_user(self, uid): - """Removes user from storage if found, otherwise raises an error. + def _create_user_import_result_fragile(self, total, errors): + """Creates a new UserImportResult instance with the given values. + + FRAGILE! The dict keys used by the UserImportResult constructor are an + implementation detail that may break in future versions of the SDK. Args: - uid: str. The Firebase account ID of the user. + total: int. The total number of records initially requested. + errors: list(tuple(int, str)). A list of (index, error) pairs. - Raises: - UserNotFoundError. The Firebase account has not been created yet. + Returns: + firebase_admin.auth.UserImportResult. The response. """ - if uid not in self._users_by_uid: - raise firebase_admin.auth.UserNotFoundError('%s not found' % uid) - del self._users_by_uid[uid] + return firebase_admin.auth.UserImportResult({ + 'error': [{'index': i, 'message': error} for i, error in errors], + }, total) - def _encode_user_claims(self, user): - """Returns encoded claims for the given user. - Args: - user: firebase_admin.auth.UserRecord. The source of the claims. +class EstablishFirebaseConnectionTests(test_utils.TestBase): - Returns: - str. An encoded representation of the user's claims. - """ - claims = {'sub': user.uid} - if user.email: - claims['email'] = user.email - if user.custom_claims: - claims.update(user.custom_claims) - return json.dumps(claims) + APP = object() - def _decode_user_claims(self, encoded_claims): - """Returns the given decoded claims. + def test_initializes_when_connection_does_not_exist(self): + get_app_swap = self.swap_with_call_counter( + firebase_admin, 'get_app', raises=ValueError('initialize_app')) + init_app_swap = self.swap_with_call_counter( + firebase_admin, 'initialize_app', returns=self.APP) - Args: - encoded_claims: str. The encoded claims. + with get_app_swap as get_app_counter, init_app_swap as init_app_counter: + firebase_auth_services.establish_firebase_connection() - Returns: - dict(str: *). The decoded claims. - """ - try: - return json.loads(encoded_claims) - except ValueError: - return None + self.assertEqual(get_app_counter.times_called, 1) + self.assertEqual(init_app_counter.times_called, 1) + + def test_returns_existing_connection(self): + get_app_swap = self.swap_with_call_counter( + firebase_admin, 'get_app', returns=self.APP) + init_app_swap = self.swap_with_call_counter( + firebase_admin, 'initialize_app', + raises=Exception('unexpected call')) + + with get_app_swap as get_app_counter, init_app_swap as init_app_counter: + firebase_auth_services.establish_firebase_connection() + + self.assertEqual(get_app_counter.times_called, 1) + self.assertEqual(init_app_counter.times_called, 0) + + def test_raises_authentic_get_app_error(self): + get_app_swap = self.swap_with_call_counter( + firebase_admin, 'get_app', raises=ValueError('uh-oh!')) + init_app_swap = self.swap_with_call_counter( + firebase_admin, 'initialize_app', + raises=Exception('unexpected call')) + + with get_app_swap as get_app_counter, init_app_swap as init_app_counter: + self.assertRaisesRegexp( + ValueError, 'uh-oh!', + firebase_auth_services.establish_firebase_connection) + + self.assertEqual(get_app_counter.times_called, 1) + self.assertEqual(init_app_counter.times_called, 0) + + def test_raises_authentic_initialize_app_error(self): + get_app_swap = self.swap_with_call_counter( + firebase_admin, 'get_app', raises=ValueError('initialize_app')) + init_app_swap = self.swap_with_call_counter( + firebase_admin, 'initialize_app', raises=ValueError('uh-oh!')) + + with get_app_swap as get_app_counter, init_app_swap as init_app_counter: + self.assertRaisesRegexp( + ValueError, 'uh-oh!', + firebase_auth_services.establish_firebase_connection) + + self.assertEqual(get_app_counter.times_called, 1) + self.assertEqual(init_app_counter.times_called, 1) class FirebaseAuthServicesTestBase(test_utils.AppEngineTestBase): @@ -538,6 +820,193 @@ def create_response(self, session_cookie=None): return res +class SuperAdminPrivilegesTests(FirebaseAuthServicesTestBase): + + def test_updates_user_successfully(self): + auth_models.UserAuthDetailsModel(id='uid', firebase_auth_id='aid').put() + self.firebase_sdk_stub.create_user('aid') + + self.firebase_sdk_stub.assert_is_not_super_admin('aid') + + firebase_auth_services.grant_super_admin_privileges('uid') + + self.firebase_sdk_stub.assert_is_super_admin('aid') + + firebase_auth_services.revoke_super_admin_privileges('uid') + + self.firebase_sdk_stub.assert_is_not_super_admin('aid') + + def test_raises_error_when_user_does_not_exist(self): + auth_models.UserAuthDetailsModel(id='uid', firebase_auth_id=None).put() + + self.assertRaisesRegexp( + ValueError, 'user_id=uid has no Firebase account', + lambda: firebase_auth_services.grant_super_admin_privileges('uid')) + + self.assertRaisesRegexp( + ValueError, 'user_id=uid has no Firebase account', + lambda: firebase_auth_services.revoke_super_admin_privileges('uid')) + + +class SeedFirebaseTests(FirebaseAuthServicesTestBase): + + def set_up_models(self, user_id=None, gae_id=None, firebase_auth_id=None): + """Creates user and auth models to emulate a real admin account. + + Args: + user_id: str|None. The Oppia ID of the user. If None, no models are + created. + gae_id: str|None. The GAE ID of the user. If None, no GAE auth + associations are created. + firebase_auth_id: str|None. The Firebase account ID of the user. If + None, no Firebase associations are created. + """ + if user_id is None: + return + user_models.UserSettingsModel( + id=user_id, email=feconf.ADMIN_EMAIL_ADDRESS).put() + auth_models.UserAuthDetailsModel( + id=user_id, gae_id=gae_id, firebase_auth_id=firebase_auth_id).put() + if gae_id is not None: + auth_models.UserIdentifiersModel(id=gae_id, user_id=user_id).put() + if firebase_auth_id is not None: + auth_models.UserIdByFirebaseAuthIdModel( + id=firebase_auth_id, user_id=user_id).put() + + def test_fails_when_no_admin_exists(self): + self.assertRaisesRegexp( + Exception, + 'testadmin@example.com must correspond to exactly 1 user', + firebase_auth_services.seed_firebase) + + def test_creates_firebase_account_and_models_if_none_exists(self): + self.set_up_models(user_id='abc') + + self.assertIsNone( + firebase_auth_services.get_auth_id_from_user_id('abc')) + self.assertIsNone( + firebase_auth_services.get_user_id_from_auth_id('abc')) + self.firebase_sdk_stub.assert_is_not_user('abc') + + firebase_auth_services.seed_firebase() + + self.assertEqual( + firebase_auth_services.get_auth_id_from_user_id('abc'), 'abc') + self.assertEqual( + firebase_auth_services.get_user_id_from_auth_id('abc'), 'abc') + self.firebase_sdk_stub.assert_is_super_admin('abc') + + def test_assigns_firebase_auth_id_to_user_id_with_uid_prefix_stripped(self): + self.set_up_models(user_id='uid_abc') + + self.assertIsNone( + firebase_auth_services.get_auth_id_from_user_id('uid_abc')) + self.assertIsNone( + firebase_auth_services.get_user_id_from_auth_id('abc')) + self.firebase_sdk_stub.assert_is_not_user('abc') + + firebase_auth_services.seed_firebase() + + self.assertEqual( + firebase_auth_services.get_auth_id_from_user_id('uid_abc'), 'abc') + self.assertEqual( + firebase_auth_services.get_user_id_from_auth_id('abc'), 'uid_abc') + self.firebase_sdk_stub.assert_is_super_admin('abc') + + def test_updates_existing_firebase_account(self): + self.set_up_models(user_id='abc') + self.firebase_sdk_stub.create_user('abc') + + self.assertIsNone( + firebase_auth_services.get_auth_id_from_user_id('abc')) + self.assertIsNone( + firebase_auth_services.get_user_id_from_auth_id('abc')) + self.firebase_sdk_stub.assert_is_not_super_admin('abc') + + firebase_auth_services.seed_firebase() + + self.assertEqual( + firebase_auth_services.get_auth_id_from_user_id('abc'), 'abc') + self.assertEqual( + firebase_auth_services.get_user_id_from_auth_id('abc'), 'abc') + self.firebase_sdk_stub.assert_is_super_admin('abc') + + def test_creates_firebase_assoc_if_missing(self): + self.set_up_models(user_id='uid_abc', gae_id='jkl') + + self.assertIsNone( + firebase_auth_services.get_auth_id_from_user_id('uid_abc')) + self.assertIsNone( + firebase_auth_services.get_user_id_from_auth_id('abc')) + self.assertIsNone( + firebase_auth_services.get_user_id_from_auth_id('jkl')) + + firebase_auth_services.seed_firebase() + + self.assertEqual( + firebase_auth_services.get_auth_id_from_user_id('uid_abc'), 'abc') + self.assertEqual( + firebase_auth_services.get_user_id_from_auth_id('abc'), 'uid_abc') + self.assertIsNone( + firebase_auth_services.get_user_id_from_auth_id('jkl')) + + def test_reuses_existing_firebase_id_if_association_exists(self): + self.set_up_models(user_id='abc', firebase_auth_id='xyz') + + self.assertEqual( + firebase_auth_services.get_auth_id_from_user_id('abc'), 'xyz') + self.assertEqual( + firebase_auth_services.get_user_id_from_auth_id('xyz'), 'abc') + self.firebase_sdk_stub.assert_is_not_user('xyz') + + firebase_auth_services.seed_firebase() + + self.assertEqual( + firebase_auth_services.get_auth_id_from_user_id('abc'), 'xyz') + self.assertEqual( + firebase_auth_services.get_user_id_from_auth_id('xyz'), 'abc') + self.firebase_sdk_stub.assert_is_super_admin('xyz') + + def test_recreates_firebase_account_if_auth_id_is_wrong(self): + self.set_up_models(user_id='abc', firebase_auth_id='xyz') + self.firebase_sdk_stub.create_user( + 'jkl', email=feconf.ADMIN_EMAIL_ADDRESS) + + self.firebase_sdk_stub.assert_is_not_super_admin('jkl') + self.firebase_sdk_stub.assert_is_not_user('xyz') + + firebase_auth_services.seed_firebase() + + self.firebase_sdk_stub.assert_is_not_user('jkl') + self.firebase_sdk_stub.assert_is_super_admin('xyz') + + def test_grants_super_admin_priviliges_if_firebase_account_exists(self): + self.set_up_models(user_id='abc', firebase_auth_id='xyz') + self.firebase_sdk_stub.create_user( + 'xyz', email=feconf.ADMIN_EMAIL_ADDRESS) + + self.firebase_sdk_stub.assert_is_not_super_admin('xyz') + + firebase_auth_services.seed_firebase() + + self.firebase_sdk_stub.assert_is_super_admin('xyz') + + def test_updates_user_id_if_assoc_model_is_inconsistent(self): + self.set_up_models(user_id='abc', firebase_auth_id='xyz') + assoc_model = auth_models.UserIdByFirebaseAuthIdModel.get('xyz') + assoc_model.user_id = 'jkl' + assoc_model.update_timestamps(update_last_updated_time=False) + assoc_model.put() + + self.assertEqual( + firebase_auth_services.get_user_id_from_auth_id('xyz'), 'jkl') + + firebase_auth_services.seed_firebase() + + self.assertEqual( + firebase_auth_services.get_user_id_from_auth_id('xyz'), 'abc') + + class EstablishAuthSessionTests(FirebaseAuthServicesTestBase): def setUp(self): @@ -623,6 +1092,18 @@ def test_logs_error_when_cookie_is_invalid(self): self.assertTrue( logs[0].startswith('User session has ended and must be renewed')) + def test_grants_super_admin_privileges_to_feconf_admin_email_address(self): + cookie = firebase_admin.auth.create_session_cookie( + self.firebase_sdk_stub.create_user( + self.AUTH_ID, email=feconf.ADMIN_EMAIL_ADDRESS), + datetime.timedelta(days=1)) + + self.assertEqual( + firebase_auth_services.get_auth_claims_from_request( + self.create_request(session_cookie=cookie)), + auth_domain.AuthClaims( + self.AUTH_ID, feconf.ADMIN_EMAIL_ADDRESS, True)) + class GenericAssociationTests(FirebaseAuthServicesTestBase): @@ -783,26 +1264,6 @@ def test_disable_association_marks_user_for_deletion(self): auth_models.UserIdByFirebaseAuthIdModel.get('aid', strict=False)) self.assertTrue(firebase_admin.auth.get_user('aid').disabled) - def test_disable_association_warns_when_firebase_fails_to_init(self): - self.firebase_sdk_stub.create_user('aid') - firebase_auth_services.associate_auth_id_with_user_id( - auth_domain.AuthIdUserIdPair('aid', 'uid')) - init_swap = self.swap_to_always_raise( - firebase_admin, 'initialize_app', - error=firebase_exceptions.UnknownError('could not init')) - - self.assertIsNotNone( - auth_models.UserIdByFirebaseAuthIdModel.get('aid', strict=False)) - self.assertFalse(firebase_admin.auth.get_user('aid').disabled) - - with init_swap, self.capture_logging() as logs: - firebase_auth_services.mark_user_for_deletion('uid') - - self.assert_matches_regexps(logs, ['could not init']) - self.assertIsNone( - auth_models.UserIdByFirebaseAuthIdModel.get('aid', strict=False)) - self.assertFalse(firebase_admin.auth.get_user('aid').disabled) - def test_disable_association_warns_when_firebase_fails_to_update_user(self): self.firebase_sdk_stub.create_user('aid') firebase_auth_services.associate_auth_id_with_user_id( @@ -824,6 +1285,14 @@ def test_disable_association_warns_when_firebase_fails_to_update_user(self): auth_models.UserIdByFirebaseAuthIdModel.get('aid', strict=False)) self.assertFalse(firebase_admin.auth.get_user('aid').disabled) + def test_disable_association_gives_up_when_auth_assocs_do_not_exist(self): + with self.capture_logging() as logs: + firebase_auth_services.mark_user_for_deletion('uid') + + self.assert_matches_regexps(logs, [ + r'\[WIPEOUT\] User with user_id=uid has no Firebase account' + ]) + class FirebaseSpecificAssociationTests(FirebaseAuthServicesTestBase): @@ -836,32 +1305,6 @@ def setUp(self): firebase_auth_services.associate_auth_id_with_user_id( auth_domain.AuthIdUserIdPair(self.AUTH_ID, self.USER_ID)) - def test_delete_user_without_firebase_initialization_returns_false(self): - init_swap = self.swap_to_always_raise( - firebase_admin, 'initialize_app', - error=firebase_exceptions.UnknownError('could not init')) - - with init_swap, self.capture_logging() as logs: - firebase_auth_services.delete_external_auth_associations( - self.USER_ID) - - self.assertFalse( - firebase_auth_services - .verify_external_auth_associations_are_deleted(self.USER_ID)) - self.assert_matches_regexps(logs, ['could not init']) - - def test_is_associated_auth_id_deleted_without_init_returns_false(self): - init_swap = self.swap_to_always_raise( - firebase_admin, 'initialize_app', - error=firebase_exceptions.UnknownError('could not init')) - - with init_swap, self.capture_logging() as logs: - self.assertFalse( - firebase_auth_services - .verify_external_auth_associations_are_deleted(self.USER_ID)) - - self.assert_matches_regexps(logs, ['could not init']) - def test_delete_user_when_firebase_raises_an_error(self): delete_swap = self.swap_to_always_raise( firebase_admin.auth, 'delete_user', @@ -900,29 +1343,6 @@ def setUp(self): self.firebase_sdk_stub.create_user(self.AUTH_ID) user_settings = user_services.create_new_user(self.AUTH_ID, self.EMAIL) self.user_id = user_settings.user_id - firebase_auth_services.associate_auth_id_with_user_id( - auth_domain.AuthIdUserIdPair(self.AUTH_ID, self.user_id)) - - def delete_external_auth_associations(self): - """Runs delete_external_auth_associations on the test user.""" - firebase_auth_services.delete_external_auth_associations(self.user_id) - - def assert_firebase_account_is_deleted(self): - """Asserts that the Firebase account has been deleted.""" - self.assertRaisesRegexp( - firebase_admin.auth.UserNotFoundError, 'not found', - lambda: firebase_admin.auth.get_user(self.AUTH_ID)) - - def assert_firebase_account_is_not_deleted(self): - """Asserts that the Firebase account still exists.""" - user = firebase_admin.auth.get_user(self.AUTH_ID) - self.assertIsNotNone(user) - self.assertEqual(user.uid, self.AUTH_ID) - - def swap_initialize_sdk_to_always_fail(self): - """Swaps the initialize_app function so that it always fails.""" - return self.swap_to_always_raise( - firebase_admin, 'initialize_app', error=self.UNKNOWN_ERROR) def swap_get_user_to_always_fail(self): """Swaps the get_user function so that it always fails.""" @@ -935,60 +1355,39 @@ def swap_delete_user_to_always_fail(self): firebase_admin.auth, 'delete_user', error=self.UNKNOWN_ERROR) def test_delete_external_auth_associations_happy_path(self): - self.delete_external_auth_associations() - - self.assert_firebase_account_is_deleted() - self.assertTrue( - firebase_auth_services - .verify_external_auth_associations_are_deleted(self.user_id)) - - def test_delete_external_auth_associations_after_failed_attempt(self): - with self.swap_initialize_sdk_to_always_fail(): - self.delete_external_auth_associations() - - self.assert_firebase_account_is_not_deleted() - self.assertFalse( - firebase_auth_services - .verify_external_auth_associations_are_deleted(self.user_id)) - - self.delete_external_auth_associations() + firebase_auth_services.delete_external_auth_associations(self.user_id) - self.assert_firebase_account_is_deleted() + self.firebase_sdk_stub.assert_is_not_user(self.AUTH_ID) self.assertTrue( firebase_auth_services .verify_external_auth_associations_are_deleted(self.user_id)) - def test_verify_delete_external_auth_associations_after_failed_attempt( - self): - self.delete_external_auth_associations() - - self.assert_firebase_account_is_deleted() - - with self.swap_initialize_sdk_to_always_fail(): - self.assertFalse( - firebase_auth_services - .verify_external_auth_associations_are_deleted(self.user_id)) + def test_delete_external_auth_associations_when_user_not_found(self): + firebase_admin.auth.delete_user(self.AUTH_ID) - self.delete_external_auth_associations() + with self.capture_logging() as logs: + firebase_auth_services.delete_external_auth_associations( + self.user_id) - self.assertTrue( - firebase_auth_services - .verify_external_auth_associations_are_deleted(self.user_id)) + self.assert_matches_regexps(logs, [ + r'\[WIPEOUT\] Firebase account already deleted', + ]) def test_delete_external_auth_associations_when_delete_user_fails(self): with self.swap_delete_user_to_always_fail(): - self.delete_external_auth_associations() + firebase_auth_services.delete_external_auth_associations( + self.user_id) - self.assert_firebase_account_is_not_deleted() + self.firebase_sdk_stub.assert_is_user(self.AUTH_ID) self.assertFalse( firebase_auth_services .verify_external_auth_associations_are_deleted(self.user_id)) def test_delete_external_auth_associations_when_get_user_fails(self): - self.delete_external_auth_associations() + firebase_auth_services.delete_external_auth_associations(self.user_id) - self.assert_firebase_account_is_deleted() + self.firebase_sdk_stub.assert_is_not_user(self.AUTH_ID) with self.swap_get_user_to_always_fail(): self.assertFalse( @@ -998,29 +1397,3 @@ def test_delete_external_auth_associations_when_get_user_fails(self): self.assertTrue( firebase_auth_services .verify_external_auth_associations_are_deleted(self.user_id)) - - def test_delete_external_auth_associations_when_init_fails_during_delete( - self): - with self.swap_initialize_sdk_to_always_fail(): - self.delete_external_auth_associations() - - self.assert_firebase_account_is_not_deleted() - - self.assertFalse( - firebase_auth_services - .verify_external_auth_associations_are_deleted(self.user_id)) - - def test_delete_external_auth_associations_when_init_fails_during_verify( - self): - self.delete_external_auth_associations() - - self.assert_firebase_account_is_deleted() - - with self.swap_initialize_sdk_to_always_fail(): - self.assertFalse( - firebase_auth_services - .verify_external_auth_associations_are_deleted(self.user_id)) - - self.assertTrue( - firebase_auth_services - .verify_external_auth_associations_are_deleted(self.user_id)) diff --git a/core/platform/models.py b/core/platform/models.py index a2cd9745b75f..9a50ad23b91f 100644 --- a/core/platform/models.py +++ b/core/platform/models.py @@ -191,13 +191,13 @@ def get_all_storage_model_classes(cls): @classmethod def import_auth_services(cls): - """Imports and returns gae_auth_services module. + """Imports and returns firebase_auth_services module. Returns: - module. The gae_auth_services module. + module. The firebase_auth_services module. """ - from core.platform.auth import gae_auth_services - return gae_auth_services + from core.platform.auth import firebase_auth_services + return firebase_auth_services @classmethod def import_transaction_services(cls): diff --git a/core/platform/models_test.py b/core/platform/models_test.py index fc942b3004f3..a466a60a7189 100644 --- a/core/platform/models_test.py +++ b/core/platform/models_test.py @@ -244,10 +244,10 @@ def test_import_transaction_services(self): def test_import_auth_services(self): """Tests import auth services function.""" - from core.platform.auth import gae_auth_services + from core.platform.auth import firebase_auth_services self.assertIs( self.registry_instance.import_auth_services(), - gae_auth_services) + firebase_auth_services) def test_import_app_identity_services(self): """Tests import app identity services function.""" diff --git a/core/storage/auth/gae_models.py b/core/storage/auth/gae_models.py index acdf306c1e86..0c7ca11ed9b5 100644 --- a/core/storage/auth/gae_models.py +++ b/core/storage/auth/gae_models.py @@ -26,6 +26,8 @@ [models.NAMES.base_model, models.NAMES.user]) datastore_services = models.Registry.import_datastore_services() +ONLY_FIREBASE_SEED_MODEL_ID = '1' + class UserAuthDetailsModel(base_models.BaseModel): """Stores the authentication details for a particular user. @@ -292,3 +294,22 @@ def get_by_user_id(cls, user_id): to user_id argument. """ return cls.query(cls.user_id == user_id).get() + + +class FirebaseSeedModel(base_models.BaseModel): + """Dummy model used to kick-off the DestroyFirebaseAccountsOneOffJob.""" + + @staticmethod + def get_deletion_policy(): + """Model should never be erased.""" + return base_models.DELETION_POLICY.KEEP + + @staticmethod + def get_model_association_to_user(): + """Model does not correspond to any users.""" + return base_models.MODEL_ASSOCIATION_TO_USER.NOT_CORRESPONDING_TO_USER + + @classmethod + def has_reference_to_user_id(cls, unused_user_id): + """Model does not correspond to any users.""" + return False diff --git a/core/storage/auth/gae_models_test.py b/core/storage/auth/gae_models_test.py index c07046f84c8d..7ef57b25ac00 100644 --- a/core/storage/auth/gae_models_test.py +++ b/core/storage/auth/gae_models_test.py @@ -292,3 +292,24 @@ def test_get_export_policy(self): 'last_updated': base_models.EXPORT_POLICY.NOT_APPLICABLE, 'user_id': base_models.EXPORT_POLICY.NOT_APPLICABLE, }) + + +class FirebaseSeedModelTests(test_utils.GenericTestBase): + """Tests for auth_models.FirebaseSeedModel.""" + + USER_ID = 'user_id' + + def test_get_deletion_policy(self): + self.assertEqual( + auth_models.FirebaseSeedModel.get_deletion_policy(), + base_models.DELETION_POLICY.KEEP) + + def test_get_model_association_to_user(self): + self.assertEqual( + auth_models.FirebaseSeedModel.get_model_association_to_user(), + base_models.MODEL_ASSOCIATION_TO_USER.NOT_CORRESPONDING_TO_USER) + + def test_has_reference_to_existing_user_id(self): + self.assertFalse( + auth_models.FirebaseSeedModel.has_reference_to_user_id( + self.USER_ID)) diff --git a/core/templates/components/common-layout-directives/navigation-bars/top-navigation-bar.directive.html b/core/templates/components/common-layout-directives/navigation-bars/top-navigation-bar.directive.html index 427a57d38d50..dca7e65321ae 100644 --- a/core/templates/components/common-layout-directives/navigation-bars/top-navigation-bar.directive.html +++ b/core/templates/components/common-layout-directives/navigation-bars/top-navigation-bar.directive.html @@ -269,7 +269,7 @@