mirror of https://github.com/zulip/zulip.git
auth: Implement server side of desktop_flow_otp.
This commit is contained in:
parent
8d987ba5ae
commit
859bde482d
|
@ -512,6 +512,7 @@ class SocialAuthBase(ZulipTestCase):
|
|||
def prepare_login_url_and_headers(self,
|
||||
subdomain: Optional[str]=None,
|
||||
mobile_flow_otp: Optional[str]=None,
|
||||
desktop_flow_otp: Optional[str]=None,
|
||||
is_signup: Optional[str]=None,
|
||||
next: str='',
|
||||
multiuse_object_key: str='',
|
||||
|
@ -528,6 +529,8 @@ class SocialAuthBase(ZulipTestCase):
|
|||
if mobile_flow_otp is not None:
|
||||
params['mobile_flow_otp'] = mobile_flow_otp
|
||||
headers['HTTP_USER_AGENT'] = "ZulipAndroid"
|
||||
if desktop_flow_otp is not None:
|
||||
params['desktop_flow_otp'] = desktop_flow_otp
|
||||
if is_signup is not None:
|
||||
url = self.SIGNUP_URL
|
||||
params['next'] = next
|
||||
|
@ -540,6 +543,7 @@ class SocialAuthBase(ZulipTestCase):
|
|||
def social_auth_test(self, account_data_dict: Dict[str, str],
|
||||
*, subdomain: Optional[str]=None,
|
||||
mobile_flow_otp: Optional[str]=None,
|
||||
desktop_flow_otp: Optional[str]=None,
|
||||
is_signup: Optional[str]=None,
|
||||
next: str='',
|
||||
multiuse_object_key: str='',
|
||||
|
@ -547,7 +551,8 @@ class SocialAuthBase(ZulipTestCase):
|
|||
alternative_start_url: Optional[str]=None,
|
||||
**extra_data: Any) -> HttpResponse:
|
||||
url, headers = self.prepare_login_url_and_headers(
|
||||
subdomain, mobile_flow_otp, is_signup, next, multiuse_object_key, alternative_start_url
|
||||
subdomain, mobile_flow_otp, desktop_flow_otp, is_signup, next,
|
||||
multiuse_object_key, alternative_start_url
|
||||
)
|
||||
|
||||
result = self.client_get(url, **headers)
|
||||
|
@ -748,6 +753,48 @@ class SocialAuthBase(ZulipTestCase):
|
|||
self.assertEqual(len(mail.outbox), 1)
|
||||
self.assertIn('Zulip on Android', mail.outbox[0].body)
|
||||
|
||||
def test_social_auth_desktop_success(self) -> None:
|
||||
desktop_flow_otp = '1234abcd' * 8
|
||||
account_data_dict = self.get_account_data_dict(email=self.email, name='Full Name')
|
||||
|
||||
# Verify that the right thing happens with an invalid-format OTP
|
||||
result = self.social_auth_test(account_data_dict, subdomain='zulip',
|
||||
desktop_flow_otp="1234")
|
||||
self.assert_json_error(result, "Invalid OTP")
|
||||
result = self.social_auth_test(account_data_dict, subdomain='zulip',
|
||||
desktop_flow_otp="invalido" * 8)
|
||||
self.assert_json_error(result, "Invalid OTP")
|
||||
|
||||
# Now do it correctly
|
||||
result = self.social_auth_test(account_data_dict, subdomain='zulip',
|
||||
expect_choose_email_screen=True,
|
||||
desktop_flow_otp=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"], [self.example_email("hamlet")])
|
||||
|
||||
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)
|
||||
self.assertEqual(result.status_code, 302)
|
||||
self.assert_logged_in_user_id(self.user_profile.id)
|
||||
|
||||
def test_social_auth_mobile_and_desktop_flow_in_one_request_error(self) -> None:
|
||||
otp = '1234abcd' * 8
|
||||
account_data_dict = self.get_account_data_dict(email=self.email, name='Full Name')
|
||||
|
||||
result = self.social_auth_test(account_data_dict, subdomain='zulip',
|
||||
expect_choose_email_screen=True,
|
||||
desktop_flow_otp=otp, mobile_flow_otp=otp)
|
||||
self.assert_json_error(result, "Can't use both mobile_flow_otp and desktop_flow_otp together.")
|
||||
|
||||
def test_social_auth_registration_existing_account(self) -> None:
|
||||
"""If the user already exists, signup flow just logs them in"""
|
||||
email = "hamlet@zulip.com"
|
||||
|
@ -1039,12 +1086,13 @@ class SAMLAuthBackendTest(SocialAuthBase):
|
|||
def social_auth_test(self, account_data_dict: Dict[str, str],
|
||||
*, subdomain: Optional[str]=None,
|
||||
mobile_flow_otp: Optional[str]=None,
|
||||
desktop_flow_otp: Optional[str]=None,
|
||||
is_signup: Optional[str]=None,
|
||||
next: str='',
|
||||
multiuse_object_key: str='',
|
||||
**extra_data: Any) -> HttpResponse:
|
||||
url, headers = self.prepare_login_url_and_headers(
|
||||
subdomain, mobile_flow_otp, is_signup, next, multiuse_object_key
|
||||
subdomain, mobile_flow_otp, desktop_flow_otp, is_signup, next, multiuse_object_key
|
||||
)
|
||||
|
||||
result = self.client_get(url, **headers)
|
||||
|
|
|
@ -198,6 +198,8 @@ def register_remote_user(request: HttpRequest, remote_username: str,
|
|||
def login_or_register_remote_user(request: HttpRequest, remote_username: str,
|
||||
user_profile: Optional[UserProfile], full_name: str='',
|
||||
mobile_flow_otp: Optional[str]=None,
|
||||
desktop_flow_otp: Optional[str]=None,
|
||||
realm: Optional[Realm]=None,
|
||||
is_signup: bool=False, redirect_to: str='',
|
||||
multiuse_object_key: str='',
|
||||
full_name_validated: bool=False) -> HttpResponse:
|
||||
|
@ -216,8 +218,8 @@ def login_or_register_remote_user(request: HttpRequest, remote_username: str,
|
|||
Zulip account but is_signup is False (i.e. the user tried to login
|
||||
and then did social authentication selecting an email address that does
|
||||
not have a Zulip account in this organization).
|
||||
* A zulip:// URL to send control back to the mobile apps if they
|
||||
are doing authentication using the mobile_flow_otp flow.
|
||||
* A zulip:// URL to send control back to the mobile or desktop apps if they
|
||||
are doing authentication using the mobile_flow_otp or desktop_flow_otp flow.
|
||||
"""
|
||||
if user_profile is None or user_profile.is_mirror_dummy:
|
||||
return register_remote_user(request, remote_username, full_name,
|
||||
|
@ -229,17 +231,38 @@ def login_or_register_remote_user(request: HttpRequest, remote_username: str,
|
|||
# or not they're using the mobile OTP flow or want a browser session.
|
||||
if mobile_flow_otp is not None:
|
||||
return finish_mobile_flow(request, user_profile, mobile_flow_otp)
|
||||
elif desktop_flow_otp is not None:
|
||||
assert realm is not None
|
||||
return finish_desktop_flow(request, user_profile, realm, desktop_flow_otp)
|
||||
|
||||
do_login(request, user_profile)
|
||||
|
||||
redirect_to = get_safe_redirect_to(redirect_to, user_profile.realm.uri)
|
||||
return HttpResponseRedirect(redirect_to)
|
||||
|
||||
def finish_desktop_flow(request: HttpRequest, user_profile: UserProfile,
|
||||
realm: Realm, otp: str) -> HttpResponse:
|
||||
"""
|
||||
The desktop otp flow returns to the app (through a zulip:// redirect)
|
||||
a token that allows obtaining (through log_into_subdomain) a logged in session
|
||||
for the user account we authenticated in this flow.
|
||||
The token can only be used once and within LOGIN_KEY_EXPIRATION_SECONDS
|
||||
of being created, as nothing more powerful is needed for the desktop flow
|
||||
and this ensures the key can only be used for completing this authentication attempt.
|
||||
"""
|
||||
data = {'email': user_profile.delivery_email,
|
||||
'subdomain': realm.subdomain}
|
||||
token = store_login_data(data)
|
||||
|
||||
return create_response_for_otp_flow(token, otp, user_profile,
|
||||
encrypted_key_field_name='otp_encrypted_login_key')
|
||||
|
||||
def finish_mobile_flow(request: HttpRequest, user_profile: UserProfile, otp: str) -> HttpResponse:
|
||||
# For the mobile Oauth flow, we send the API key and other
|
||||
# necessary details in a redirect to a zulip:// URI scheme.
|
||||
api_key = get_api_key(user_profile)
|
||||
response = create_response_for_otp_flow(api_key, otp, user_profile)
|
||||
response = create_response_for_otp_flow(api_key, otp, user_profile,
|
||||
encrypted_key_field_name='otp_encrypted_api_key')
|
||||
|
||||
# Since we are returning an API key instead of going through
|
||||
# the Django login() function (which creates a browser
|
||||
|
@ -257,9 +280,10 @@ def finish_mobile_flow(request: HttpRequest, user_profile: UserProfile, otp: str
|
|||
|
||||
return response
|
||||
|
||||
def create_response_for_otp_flow(key: str, otp: str, user_profile: UserProfile) -> HttpResponse:
|
||||
def create_response_for_otp_flow(key: str, otp: str, user_profile: UserProfile,
|
||||
encrypted_key_field_name: str) -> HttpResponse:
|
||||
params = {
|
||||
'otp_encrypted_api_key': otp_encrypt_api_key(key, otp),
|
||||
encrypted_key_field_name: otp_encrypt_api_key(key, otp),
|
||||
'email': user_profile.delivery_email,
|
||||
'realm': user_profile.realm.uri,
|
||||
}
|
||||
|
@ -372,6 +396,12 @@ def oauth_redirect_to_root(request: HttpRequest, url: str,
|
|||
raise JsonableError(_("Invalid OTP"))
|
||||
params['mobile_flow_otp'] = mobile_flow_otp
|
||||
|
||||
desktop_flow_otp = request.GET.get('desktop_flow_otp')
|
||||
if desktop_flow_otp is not None:
|
||||
if not is_valid_otp(desktop_flow_otp):
|
||||
raise JsonableError(_("Invalid OTP"))
|
||||
params['desktop_flow_otp'] = desktop_flow_otp
|
||||
|
||||
next = request.GET.get('next')
|
||||
if next:
|
||||
params['next'] = next
|
||||
|
@ -451,6 +481,10 @@ def log_into_subdomain(request: HttpRequest, token: str) -> HttpResponse:
|
|||
redirect_and_log_into_subdomain called on auth.zulip.example.com),
|
||||
call login_or_register_remote_user, passing all the authentication
|
||||
result data that has been stored in redis, associated with this token.
|
||||
Obligatory fields for the data are 'subdomain' and 'email', because this endpoint
|
||||
needs to know which user and realm to log into. Others are optional and only used
|
||||
if the user account still needs to be made and they're passed as argument to the
|
||||
register_remote_user function.
|
||||
"""
|
||||
if not has_api_key_format(token): # The tokens are intended to have the same format as API keys.
|
||||
logging.warning("log_into_subdomain: Malformed token given: %s" % (token,))
|
||||
|
|
|
@ -31,6 +31,7 @@ from django.dispatch import receiver, Signal
|
|||
from django.http import HttpResponse, HttpResponseRedirect
|
||||
from django.shortcuts import render
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import ugettext as _
|
||||
from requests import HTTPError
|
||||
from onelogin.saml2.errors import OneLogin_Saml2_Error
|
||||
from social_core.backends.github import GithubOAuth2, GithubOrganizationOAuth2, \
|
||||
|
@ -1076,6 +1077,10 @@ def social_auth_finish(backend: Any,
|
|||
realm = Realm.objects.get(id=return_data["realm_id"])
|
||||
multiuse_object_key = strategy.session_get('multiuse_object_key', '')
|
||||
mobile_flow_otp = strategy.session_get('mobile_flow_otp')
|
||||
desktop_flow_otp = strategy.session_get('desktop_flow_otp')
|
||||
if mobile_flow_otp and desktop_flow_otp:
|
||||
raise JsonableError(_("Can't use both mobile_flow_otp and desktop_flow_otp together."))
|
||||
|
||||
if user_profile is None or user_profile.is_mirror_dummy:
|
||||
is_signup = strategy.session_get('is_signup') == '1'
|
||||
else:
|
||||
|
@ -1086,10 +1091,17 @@ def social_auth_finish(backend: Any,
|
|||
#
|
||||
# The next step is to call login_or_register_remote_user, but
|
||||
# there are two code paths here because of an optimization to save
|
||||
# a redirect on mobile.
|
||||
# a redirect on mobile and desktop.
|
||||
|
||||
if mobile_flow_otp is not None:
|
||||
# For mobile app authentication, login_or_register_remote_user
|
||||
if mobile_flow_otp or desktop_flow_otp:
|
||||
extra_kwargs = {}
|
||||
if mobile_flow_otp:
|
||||
extra_kwargs["mobile_flow_otp"] = mobile_flow_otp
|
||||
elif desktop_flow_otp:
|
||||
extra_kwargs["desktop_flow_otp"] = desktop_flow_otp
|
||||
extra_kwargs["realm"] = realm
|
||||
|
||||
# For mobile and desktop app authentication, login_or_register_remote_user
|
||||
# will redirect to a special zulip:// URL that is handled by
|
||||
# the app after a successful authentication; so we can
|
||||
# redirect directly from here, saving a round trip over what
|
||||
|
@ -1098,10 +1110,10 @@ def social_auth_finish(backend: Any,
|
|||
return login_or_register_remote_user(
|
||||
strategy.request, email_address,
|
||||
user_profile, full_name,
|
||||
mobile_flow_otp=mobile_flow_otp,
|
||||
is_signup=is_signup,
|
||||
redirect_to=redirect_to,
|
||||
full_name_validated=full_name_validated
|
||||
full_name_validated=full_name_validated,
|
||||
**extra_kwargs
|
||||
)
|
||||
|
||||
# If this authentication code were executing on
|
||||
|
@ -1263,7 +1275,7 @@ class GoogleAuthBackend(SocialAuthMixin, GoogleOAuth2):
|
|||
@external_auth_method
|
||||
class SAMLAuthBackend(SocialAuthMixin, SAMLAuth):
|
||||
auth_backend_name = "SAML"
|
||||
standard_relay_params = ["subdomain", "multiuse_object_key", "mobile_flow_otp",
|
||||
standard_relay_params = ["subdomain", "multiuse_object_key", "mobile_flow_otp", "desktop_flow_otp",
|
||||
"next", "is_signup"]
|
||||
REDIS_EXPIRATION_SECONDS = 60 * 15
|
||||
name = "saml"
|
||||
|
|
|
@ -964,7 +964,8 @@ if REGISTER_LINK_DISABLED is None:
|
|||
# SOCIAL AUTHENTICATION SETTINGS
|
||||
########################################################################
|
||||
|
||||
SOCIAL_AUTH_FIELDS_STORED_IN_SESSION = ['subdomain', 'is_signup', 'mobile_flow_otp', 'multiuse_object_key']
|
||||
SOCIAL_AUTH_FIELDS_STORED_IN_SESSION = ['subdomain', 'is_signup', 'mobile_flow_otp', 'desktop_flow_otp',
|
||||
'multiuse_object_key']
|
||||
SOCIAL_AUTH_LOGIN_ERROR_URL = '/login/'
|
||||
|
||||
SOCIAL_AUTH_GITHUB_SECRET = get_secret('social_auth_github_secret')
|
||||
|
|
Loading…
Reference in New Issue