registration: Add support for mobile and desktop flows.

This makes it possible to create a Zulip account from the mobile or
desktop apps and have the end result be that the user is logged in on
their mobile device.

We may need small changes in the desktop and/or mobile apps to support
this.

Closes #10859.
This commit is contained in:
Mateusz Mandera 2020-02-06 18:27:10 +01:00 committed by Tim Abbott
parent fe33966642
commit bde495db87
5 changed files with 153 additions and 20 deletions

View File

@ -965,7 +965,9 @@ class SocialAuthBase(ZulipTestCase):
def stage_two_of_registration(self, result: HttpResponse, realm: Realm, subdomain: str, def stage_two_of_registration(self, result: HttpResponse, realm: Realm, subdomain: str,
email: str, name: str, expected_final_name: str, email: str, name: str, expected_final_name: str,
skip_registration_form: bool) -> None: skip_registration_form: bool,
mobile_flow_otp: Optional[str]=None,
desktop_flow_otp: Optional[str]=None) -> None:
data = load_subdomain_token(result) data = load_subdomain_token(result)
self.assertEqual(data['email'], email) self.assertEqual(data['email'], email)
self.assertEqual(data['name'], name) self.assertEqual(data['name'], name)
@ -1003,6 +1005,35 @@ class SocialAuthBase(ZulipTestCase):
'key': confirmation_key, 'key': confirmation_key,
'terms': True}) 'terms': True})
# Mobile and desktop flow have additional steps:
if mobile_flow_otp:
self.assertEqual(result.status_code, 302)
redirect_url = result['Location']
parsed_url = urllib.parse.urlparse(redirect_url)
query_params = urllib.parse.parse_qs(parsed_url.query)
self.assertEqual(parsed_url.scheme, 'zulip')
self.assertEqual(query_params["realm"], ['http://zulip.testserver'])
self.assertEqual(query_params["email"], [email])
encrypted_api_key = query_params["otp_encrypted_api_key"][0]
user_api_keys = get_all_api_keys(get_user(email, realm))
self.assertIn(otp_decrypt_api_key(encrypted_api_key, mobile_flow_otp), user_api_keys)
return
elif desktop_flow_otp:
self.assertEqual(result.status_code, 302)
redirect_url = result['Location']
parsed_url = urllib.parse.urlparse(redirect_url)
query_params = urllib.parse.parse_qs(parsed_url.query)
self.assertEqual(parsed_url.scheme, 'zulip')
self.assertEqual(query_params["realm"], ['http://zulip.testserver'])
self.assertEqual(query_params["email"], [email])
encrypted_key = query_params["otp_encrypted_login_key"][0]
decrypted_key = otp_decrypt_api_key(encrypted_key, desktop_flow_otp)
auth_url = 'http://zulip.testserver/accounts/login/subdomain/{}'.format(decrypted_key)
result = self.client_get(auth_url)
# Now the desktop app is logged in, continue with the logged in check:
self.assertEqual(result.status_code, 302) self.assertEqual(result.status_code, 302)
user_profile = get_user(email, realm) user_profile = get_user(email, realm)
self.assert_logged_in_user_id(user_profile.id) self.assert_logged_in_user_id(user_profile.id)
@ -1024,6 +1055,40 @@ class SocialAuthBase(ZulipTestCase):
self.stage_two_of_registration(result, realm, subdomain, email, name, name, self.stage_two_of_registration(result, realm, subdomain, email, name, name,
self.BACKEND_CLASS.full_name_validated) self.BACKEND_CLASS.full_name_validated)
@override_settings(TERMS_OF_SERVICE=None)
def test_social_auth_mobile_registration(self) -> None:
email = "newuser@zulip.com"
name = 'Full Name'
subdomain = 'zulip'
realm = get_realm("zulip")
mobile_flow_otp = '1234abcd' * 8
account_data_dict = self.get_account_data_dict(email=email, name=name)
result = self.social_auth_test(account_data_dict, subdomain='zulip',
expect_choose_email_screen=True,
is_signup='1',
mobile_flow_otp=mobile_flow_otp)
self.stage_two_of_registration(result, realm, subdomain, email, name, name,
self.BACKEND_CLASS.full_name_validated,
mobile_flow_otp=mobile_flow_otp)
@override_settings(TERMS_OF_SERVICE=None)
def test_social_auth_desktop_registration(self) -> None:
email = "newuser@zulip.com"
name = 'Full Name'
subdomain = 'zulip'
realm = get_realm("zulip")
desktop_flow_otp = '1234abcd' * 8
account_data_dict = self.get_account_data_dict(email=email, name=name)
result = self.social_auth_test(account_data_dict, subdomain='zulip',
expect_choose_email_screen=True,
is_signup='1',
desktop_flow_otp=desktop_flow_otp)
self.stage_two_of_registration(result, realm, subdomain, email, name, name,
self.BACKEND_CLASS.full_name_validated,
desktop_flow_otp=desktop_flow_otp)
@override_settings(TERMS_OF_SERVICE=None) @override_settings(TERMS_OF_SERVICE=None)
def test_social_auth_registration_invitation_exists(self) -> None: def test_social_auth_registration_invitation_exists(self) -> None:
""" """

View File

@ -86,6 +86,8 @@ class RedirectAndLogIntoSubdomainTestCase(ZulipTestCase):
'full_name_validated': False, 'full_name_validated': False,
'subdomain': realm.subdomain, 'subdomain': realm.subdomain,
'is_signup': False, 'is_signup': False,
'desktop_flow_otp': None,
'mobile_flow_otp': None,
'multiuse_object_key': ''}) 'multiuse_object_key': ''})
response = redirect_and_log_into_subdomain(realm, name, email, response = redirect_and_log_into_subdomain(realm, name, email,
@ -97,6 +99,8 @@ class RedirectAndLogIntoSubdomainTestCase(ZulipTestCase):
'full_name_validated': False, 'full_name_validated': False,
'subdomain': realm.subdomain, 'subdomain': realm.subdomain,
'is_signup': True, 'is_signup': True,
'desktop_flow_otp': None,
'mobile_flow_otp': None,
'multiuse_object_key': 'key' 'multiuse_object_key': 'key'
}) })
@ -110,6 +114,8 @@ class RedirectAndLogIntoSubdomainTestCase(ZulipTestCase):
'full_name_validated': True, 'full_name_validated': True,
'subdomain': realm.subdomain, 'subdomain': realm.subdomain,
'is_signup': True, 'is_signup': True,
'desktop_flow_otp': None,
'mobile_flow_otp': None,
'multiuse_object_key': 'key' 'multiuse_object_key': 'key'
}) })

View File

@ -29,6 +29,7 @@ from zerver.lib.push_notifications import push_notifications_enabled
from zerver.lib.redis_utils import get_redis_client, get_dict_from_redis, put_dict_in_redis from zerver.lib.redis_utils import get_redis_client, get_dict_from_redis, put_dict_in_redis
from zerver.lib.request import REQ, has_request_variables, JsonableError from zerver.lib.request import REQ, has_request_variables, JsonableError
from zerver.lib.response import json_success, json_error from zerver.lib.response import json_success, json_error
from zerver.lib.sessions import set_expirable_session_var
from zerver.lib.subdomains import get_subdomain, is_subdomain_root_or_alias from zerver.lib.subdomains import get_subdomain, is_subdomain_root_or_alias
from zerver.lib.user_agent import parse_user_agent from zerver.lib.user_agent import parse_user_agent
from zerver.lib.users import get_api_key from zerver.lib.users import get_api_key
@ -82,6 +83,8 @@ def create_preregistration_user(email: str, request: HttpRequest, realm_creation
) )
def maybe_send_to_registration(request: HttpRequest, email: str, full_name: str='', def maybe_send_to_registration(request: HttpRequest, email: str, full_name: str='',
mobile_flow_otp: Optional[str]=None,
desktop_flow_otp: Optional[str]=None,
is_signup: bool=False, password_required: bool=True, is_signup: bool=False, password_required: bool=True,
multiuse_object_key: str='', multiuse_object_key: str='',
full_name_validated: bool=False) -> HttpResponse: full_name_validated: bool=False) -> HttpResponse:
@ -92,6 +95,31 @@ def maybe_send_to_registration(request: HttpRequest, email: str, full_name: str=
depending on is_signup, whether the email address can join the depending on is_signup, whether the email address can join the
organization (checked in HomepageForm), and similar details. organization (checked in HomepageForm), and similar details.
""" """
# In the desktop and mobile registration flows, the sign up
# happens in the browser so the user can use their
# already-logged-in social accounts. Then at the end, with the
# user account created, we pass the appropriate data to the app
# via e.g. a `zulip://` redirect. We store the OTP keys for the
# mobile/desktop flow in the session with 1-hour expiry, because
# we want this configuration of having a successful authentication
# result in being logged into the app to persist if the user makes
# mistakes while trying to authenticate (E.g. clicks the wrong
# Google account, hits back, etc.) during a given browser session,
# rather than just logging into the webapp in the target browser.
#
# We can't use our usual pre-account-creation state storage
# approach of putting something in PreregistrationUser, because
# that would apply to future registration attempts on other
# devices, e.g. just creating an account on the web on their laptop.
assert not (mobile_flow_otp and desktop_flow_otp)
if mobile_flow_otp:
set_expirable_session_var(request.session, 'registration_mobile_flow_otp', mobile_flow_otp,
expiry_seconds=3600)
elif desktop_flow_otp:
set_expirable_session_var(request.session, 'registration_desktop_flow_otp', desktop_flow_otp,
expiry_seconds=3600)
if multiuse_object_key: if multiuse_object_key:
from_multiuse_invite = True from_multiuse_invite = True
multiuse_obj = Confirmation.objects.get(confirmation_key=multiuse_object_key).content_object multiuse_obj = Confirmation.objects.get(confirmation_key=multiuse_object_key).content_object
@ -172,7 +200,9 @@ def maybe_send_to_registration(request: HttpRequest, email: str, full_name: str=
context = login_context(request) context = login_context(request)
extra_context = {'form': form, 'current_url': lambda: url, extra_context = {'form': form, 'current_url': lambda: url,
'from_multiuse_invite': from_multiuse_invite, 'from_multiuse_invite': from_multiuse_invite,
'multiuse_object_key': multiuse_object_key} # type: Mapping[str, Any] 'multiuse_object_key': multiuse_object_key,
'mobile_flow_otp': mobile_flow_otp,
'desktop_flow_otp': desktop_flow_otp} # type: Mapping[str, Any]
context.update(extra_context) context.update(extra_context)
return render(request, 'zerver/accounts_home.html', context=context) return render(request, 'zerver/accounts_home.html', context=context)
@ -183,6 +213,8 @@ def redirect_to_subdomain_login_url() -> HttpResponseRedirect:
def register_remote_user(request: HttpRequest, remote_username: str, def register_remote_user(request: HttpRequest, remote_username: str,
full_name: str='', full_name: str='',
mobile_flow_otp: Optional[str]=None,
desktop_flow_otp: Optional[str]=None,
is_signup: bool=False, is_signup: bool=False,
multiuse_object_key: str='', multiuse_object_key: str='',
full_name_validated: bool=False) -> HttpResponse: full_name_validated: bool=False) -> HttpResponse:
@ -191,6 +223,8 @@ def register_remote_user(request: HttpRequest, remote_username: str,
# there's no associated Zulip user account. Consider sending # there's no associated Zulip user account. Consider sending
# the request to registration. # the request to registration.
return maybe_send_to_registration(request, email, full_name, password_required=False, return maybe_send_to_registration(request, email, full_name, password_required=False,
mobile_flow_otp=mobile_flow_otp,
desktop_flow_otp=desktop_flow_otp,
is_signup=is_signup, multiuse_object_key=multiuse_object_key, is_signup=is_signup, multiuse_object_key=multiuse_object_key,
full_name_validated=full_name_validated) full_name_validated=full_name_validated)
@ -222,7 +256,10 @@ def login_or_register_remote_user(request: HttpRequest, remote_username: str,
""" """
if user_profile is None or user_profile.is_mirror_dummy: if user_profile is None or user_profile.is_mirror_dummy:
return register_remote_user(request, remote_username, full_name, return register_remote_user(request, remote_username, full_name,
is_signup=is_signup, multiuse_object_key=multiuse_object_key, is_signup=is_signup,
mobile_flow_otp=mobile_flow_otp,
desktop_flow_otp=desktop_flow_otp,
multiuse_object_key=multiuse_object_key,
full_name_validated=full_name_validated) full_name_validated=full_name_validated)
# Otherwise, the user has successfully authenticated to an # Otherwise, the user has successfully authenticated to an
@ -526,6 +563,8 @@ def log_into_subdomain(request: HttpRequest, token: str) -> HttpResponse:
full_name = data.get('name', '') full_name = data.get('name', '')
is_signup = data.get('is_signup', False) is_signup = data.get('is_signup', False)
redirect_to = data.get('next', '') redirect_to = data.get('next', '')
mobile_flow_otp = data.get('mobile_flow_otp')
desktop_flow_otp = data.get('desktop_flow_otp')
full_name_validated = data.get('full_name_validated', False) full_name_validated = data.get('full_name_validated', False)
multiuse_object_key = data.get('multiuse_object_key', '') multiuse_object_key = data.get('multiuse_object_key', '')
@ -561,6 +600,8 @@ def log_into_subdomain(request: HttpRequest, token: str) -> HttpResponse:
return login_or_register_remote_user(request, email_address, user_profile, return login_or_register_remote_user(request, email_address, user_profile,
full_name, full_name,
is_signup=is_signup, redirect_to=redirect_to, is_signup=is_signup, redirect_to=redirect_to,
mobile_flow_otp=mobile_flow_otp,
desktop_flow_otp=desktop_flow_otp,
multiuse_object_key=multiuse_object_key, multiuse_object_key=multiuse_object_key,
full_name_validated=full_name_validated) full_name_validated=full_name_validated)
@ -580,10 +621,14 @@ def get_login_data(token: str, should_delete: bool=True) -> Optional[Dict[str, A
def redirect_and_log_into_subdomain(realm: Realm, full_name: str, email_address: str, def redirect_and_log_into_subdomain(realm: Realm, full_name: str, email_address: str,
is_signup: bool=False, redirect_to: str='', is_signup: bool=False, redirect_to: str='',
mobile_flow_otp: Optional[str]=None,
desktop_flow_otp: Optional[str]=None,
multiuse_object_key: str='', multiuse_object_key: str='',
full_name_validated: bool=False) -> HttpResponse: full_name_validated: bool=False,) -> HttpResponse:
data = {'name': full_name, 'email': email_address, 'subdomain': realm.subdomain, data = {'name': full_name, 'email': email_address, 'subdomain': realm.subdomain,
'is_signup': is_signup, 'next': redirect_to, 'is_signup': is_signup, 'next': redirect_to,
'mobile_flow_otp': mobile_flow_otp,
'desktop_flow_otp': desktop_flow_otp,
'multiuse_object_key': multiuse_object_key, 'multiuse_object_key': multiuse_object_key,
'full_name_validated': full_name_validated} 'full_name_validated': full_name_validated}
token = store_login_data(data) token = store_login_data(data)

View File

@ -26,12 +26,13 @@ from django_auth_ldap.backend import LDAPBackend, _LDAPUser
from zerver.decorator import require_post, \ from zerver.decorator import require_post, \
do_login do_login
from zerver.lib.onboarding import send_initial_realm_messages, setup_realm_internal_bots from zerver.lib.onboarding import send_initial_realm_messages, setup_realm_internal_bots
from zerver.lib.sessions import get_expirable_session_var
from zerver.lib.subdomains import get_subdomain, is_root_domain_available from zerver.lib.subdomains import get_subdomain, is_root_domain_available
from zerver.lib.timezone import get_all_timezones from zerver.lib.timezone import get_all_timezones
from zerver.lib.users import get_accounts_for_email from zerver.lib.users import get_accounts_for_email
from zerver.lib.zephyr import compute_mit_user_fullname from zerver.lib.zephyr import compute_mit_user_fullname
from zerver.views.auth import create_preregistration_user, redirect_and_log_into_subdomain, \ from zerver.views.auth import create_preregistration_user, redirect_and_log_into_subdomain, \
redirect_to_deactivation_notice, get_safe_redirect_to redirect_to_deactivation_notice, get_safe_redirect_to, finish_desktop_flow, finish_mobile_flow
from zproject.backends import ldap_auth_enabled, password_auth_enabled, \ from zproject.backends import ldap_auth_enabled, password_auth_enabled, \
ZulipLDAPExceptionNoMatchingLDAPUser, email_auth_enabled, ZulipLDAPAuthBackend, \ ZulipLDAPExceptionNoMatchingLDAPUser, email_auth_enabled, ZulipLDAPAuthBackend, \
@ -380,6 +381,15 @@ def accounts_register(request: HttpRequest) -> HttpResponse:
) )
def login_and_go_to_home(request: HttpRequest, user_profile: UserProfile) -> HttpResponse: def login_and_go_to_home(request: HttpRequest, user_profile: UserProfile) -> HttpResponse:
mobile_flow_otp = get_expirable_session_var(request.session, 'registration_mobile_flow_otp',
delete=True)
desktop_flow_otp = get_expirable_session_var(request.session, 'registration_desktop_flow_otp',
delete=True)
if mobile_flow_otp is not None:
return finish_mobile_flow(request, user_profile, mobile_flow_otp)
elif desktop_flow_otp is not None:
return finish_desktop_flow(request, user_profile, user_profile.realm, desktop_flow_otp)
do_login(request, user_profile) do_login(request, user_profile)
return HttpResponseRedirect(user_profile.realm.uri + reverse('zerver.views.home.home')) return HttpResponseRedirect(user_profile.realm.uri + reverse('zerver.views.home.home'))

View File

@ -1172,20 +1172,25 @@ def social_auth_finish(backend: Any,
extra_kwargs["desktop_flow_otp"] = desktop_flow_otp extra_kwargs["desktop_flow_otp"] = desktop_flow_otp
extra_kwargs["realm"] = realm extra_kwargs["realm"] = realm
# For mobile and desktop app authentication, login_or_register_remote_user if user_profile is not None and not user_profile.is_mirror_dummy:
# will redirect to a special zulip:// URL that is handled by # For mobile and desktop app authentication, login_or_register_remote_user
# the app after a successful authentication; so we can # will redirect to a special zulip:// URL that is handled by
# redirect directly from here, saving a round trip over what # the app after a successful authentication; so we can
# we need to do to create session cookies on the right domain # redirect directly from here, saving a round trip over what
# in the web login flow (below). # we need to do to create session cookies on the right domain
return login_or_register_remote_user( # in the web login flow (below).
strategy.request, email_address, return login_or_register_remote_user(
user_profile, full_name, strategy.request, email_address,
is_signup=is_signup, user_profile, full_name,
redirect_to=redirect_to, is_signup=is_signup,
full_name_validated=full_name_validated, redirect_to=redirect_to,
**extra_kwargs full_name_validated=full_name_validated,
) **extra_kwargs
)
else:
# The user needs to register, so we need to go the realm's
# subdomain for that.
pass
# If this authentication code were executing on # If this authentication code were executing on
# subdomain.zulip.example.com, we would just call # subdomain.zulip.example.com, we would just call
@ -1205,7 +1210,9 @@ def social_auth_finish(backend: Any,
is_signup=is_signup, is_signup=is_signup,
redirect_to=redirect_to, redirect_to=redirect_to,
multiuse_object_key=multiuse_object_key, multiuse_object_key=multiuse_object_key,
full_name_validated=full_name_validated full_name_validated=full_name_validated,
mobile_flow_otp=mobile_flow_otp,
desktop_flow_otp=desktop_flow_otp
) )
class SocialAuthMixin(ZulipAuthMixin, ExternalAuthMethod): class SocialAuthMixin(ZulipAuthMixin, ExternalAuthMethod):