Skip to content

Commit

Permalink
Fix #11462: Migrate to Firebase authentication (#12392)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
brianrodri authored Apr 2, 2021
1 parent 7caa28c commit 9ca25b9
Show file tree
Hide file tree
Showing 73 changed files with 3,963 additions and 1,309 deletions.
7 changes: 7 additions & 0 deletions .firebase.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"emulators": {
"auth": {
"port": "9099"
}
}
}
3 changes: 3 additions & 0 deletions .github/CODEOWNERS
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion .isort.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -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
26 changes: 26 additions & 0 deletions app_dev.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
13 changes: 7 additions & 6 deletions assets/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,

Expand Down
46 changes: 46 additions & 0 deletions core/controllers/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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."""

Expand Down
114 changes: 114 additions & 0 deletions core/controllers/admin_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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."""
Expand Down
36 changes: 25 additions & 11 deletions core/controllers/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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

Expand All @@ -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:
Expand All @@ -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

Expand Down
Loading

0 comments on commit 9ca25b9

Please sign in to comment.