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,
|
def prepare_login_url_and_headers(self,
|
||||||
subdomain: Optional[str]=None,
|
subdomain: Optional[str]=None,
|
||||||
mobile_flow_otp: Optional[str]=None,
|
mobile_flow_otp: Optional[str]=None,
|
||||||
|
desktop_flow_otp: Optional[str]=None,
|
||||||
is_signup: Optional[str]=None,
|
is_signup: Optional[str]=None,
|
||||||
next: str='',
|
next: str='',
|
||||||
multiuse_object_key: str='',
|
multiuse_object_key: str='',
|
||||||
|
@ -528,6 +529,8 @@ class SocialAuthBase(ZulipTestCase):
|
||||||
if mobile_flow_otp is not None:
|
if mobile_flow_otp is not None:
|
||||||
params['mobile_flow_otp'] = mobile_flow_otp
|
params['mobile_flow_otp'] = mobile_flow_otp
|
||||||
headers['HTTP_USER_AGENT'] = "ZulipAndroid"
|
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:
|
if is_signup is not None:
|
||||||
url = self.SIGNUP_URL
|
url = self.SIGNUP_URL
|
||||||
params['next'] = next
|
params['next'] = next
|
||||||
|
@ -540,6 +543,7 @@ class SocialAuthBase(ZulipTestCase):
|
||||||
def social_auth_test(self, account_data_dict: Dict[str, str],
|
def social_auth_test(self, account_data_dict: Dict[str, str],
|
||||||
*, subdomain: Optional[str]=None,
|
*, subdomain: Optional[str]=None,
|
||||||
mobile_flow_otp: Optional[str]=None,
|
mobile_flow_otp: Optional[str]=None,
|
||||||
|
desktop_flow_otp: Optional[str]=None,
|
||||||
is_signup: Optional[str]=None,
|
is_signup: Optional[str]=None,
|
||||||
next: str='',
|
next: str='',
|
||||||
multiuse_object_key: str='',
|
multiuse_object_key: str='',
|
||||||
|
@ -547,7 +551,8 @@ class SocialAuthBase(ZulipTestCase):
|
||||||
alternative_start_url: Optional[str]=None,
|
alternative_start_url: Optional[str]=None,
|
||||||
**extra_data: Any) -> HttpResponse:
|
**extra_data: Any) -> HttpResponse:
|
||||||
url, headers = self.prepare_login_url_and_headers(
|
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)
|
result = self.client_get(url, **headers)
|
||||||
|
@ -748,6 +753,48 @@ class SocialAuthBase(ZulipTestCase):
|
||||||
self.assertEqual(len(mail.outbox), 1)
|
self.assertEqual(len(mail.outbox), 1)
|
||||||
self.assertIn('Zulip on Android', mail.outbox[0].body)
|
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:
|
def test_social_auth_registration_existing_account(self) -> None:
|
||||||
"""If the user already exists, signup flow just logs them in"""
|
"""If the user already exists, signup flow just logs them in"""
|
||||||
email = "hamlet@zulip.com"
|
email = "hamlet@zulip.com"
|
||||||
|
@ -1039,12 +1086,13 @@ class SAMLAuthBackendTest(SocialAuthBase):
|
||||||
def social_auth_test(self, account_data_dict: Dict[str, str],
|
def social_auth_test(self, account_data_dict: Dict[str, str],
|
||||||
*, subdomain: Optional[str]=None,
|
*, subdomain: Optional[str]=None,
|
||||||
mobile_flow_otp: Optional[str]=None,
|
mobile_flow_otp: Optional[str]=None,
|
||||||
|
desktop_flow_otp: Optional[str]=None,
|
||||||
is_signup: Optional[str]=None,
|
is_signup: Optional[str]=None,
|
||||||
next: str='',
|
next: str='',
|
||||||
multiuse_object_key: str='',
|
multiuse_object_key: str='',
|
||||||
**extra_data: Any) -> HttpResponse:
|
**extra_data: Any) -> HttpResponse:
|
||||||
url, headers = self.prepare_login_url_and_headers(
|
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)
|
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,
|
def login_or_register_remote_user(request: HttpRequest, remote_username: str,
|
||||||
user_profile: Optional[UserProfile], full_name: str='',
|
user_profile: Optional[UserProfile], full_name: str='',
|
||||||
mobile_flow_otp: Optional[str]=None,
|
mobile_flow_otp: Optional[str]=None,
|
||||||
|
desktop_flow_otp: Optional[str]=None,
|
||||||
|
realm: Optional[Realm]=None,
|
||||||
is_signup: bool=False, redirect_to: str='',
|
is_signup: bool=False, redirect_to: str='',
|
||||||
multiuse_object_key: str='',
|
multiuse_object_key: str='',
|
||||||
full_name_validated: bool=False) -> HttpResponse:
|
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
|
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
|
and then did social authentication selecting an email address that does
|
||||||
not have a Zulip account in this organization).
|
not have a Zulip account in this organization).
|
||||||
* A zulip:// URL to send control back to the mobile apps if they
|
* A zulip:// URL to send control back to the mobile or desktop apps if they
|
||||||
are doing authentication using the mobile_flow_otp flow.
|
are doing authentication using the mobile_flow_otp or desktop_flow_otp flow.
|
||||||
"""
|
"""
|
||||||
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,
|
||||||
|
@ -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.
|
# or not they're using the mobile OTP flow or want a browser session.
|
||||||
if mobile_flow_otp is not None:
|
if mobile_flow_otp is not None:
|
||||||
return finish_mobile_flow(request, user_profile, mobile_flow_otp)
|
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)
|
do_login(request, user_profile)
|
||||||
|
|
||||||
redirect_to = get_safe_redirect_to(redirect_to, user_profile.realm.uri)
|
redirect_to = get_safe_redirect_to(redirect_to, user_profile.realm.uri)
|
||||||
return HttpResponseRedirect(redirect_to)
|
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:
|
def finish_mobile_flow(request: HttpRequest, user_profile: UserProfile, otp: str) -> HttpResponse:
|
||||||
# For the mobile Oauth flow, we send the API key and other
|
# For the mobile Oauth flow, we send the API key and other
|
||||||
# necessary details in a redirect to a zulip:// URI scheme.
|
# necessary details in a redirect to a zulip:// URI scheme.
|
||||||
api_key = get_api_key(user_profile)
|
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
|
# Since we are returning an API key instead of going through
|
||||||
# the Django login() function (which creates a browser
|
# 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
|
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 = {
|
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,
|
'email': user_profile.delivery_email,
|
||||||
'realm': user_profile.realm.uri,
|
'realm': user_profile.realm.uri,
|
||||||
}
|
}
|
||||||
|
@ -372,6 +396,12 @@ def oauth_redirect_to_root(request: HttpRequest, url: str,
|
||||||
raise JsonableError(_("Invalid OTP"))
|
raise JsonableError(_("Invalid OTP"))
|
||||||
params['mobile_flow_otp'] = mobile_flow_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')
|
next = request.GET.get('next')
|
||||||
if next:
|
if next:
|
||||||
params['next'] = 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),
|
redirect_and_log_into_subdomain called on auth.zulip.example.com),
|
||||||
call login_or_register_remote_user, passing all the authentication
|
call login_or_register_remote_user, passing all the authentication
|
||||||
result data that has been stored in redis, associated with this token.
|
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.
|
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,))
|
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.http import HttpResponse, HttpResponseRedirect
|
||||||
from django.shortcuts import render
|
from django.shortcuts import render
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
from django.utils.translation import ugettext as _
|
||||||
from requests import HTTPError
|
from requests import HTTPError
|
||||||
from onelogin.saml2.errors import OneLogin_Saml2_Error
|
from onelogin.saml2.errors import OneLogin_Saml2_Error
|
||||||
from social_core.backends.github import GithubOAuth2, GithubOrganizationOAuth2, \
|
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"])
|
realm = Realm.objects.get(id=return_data["realm_id"])
|
||||||
multiuse_object_key = strategy.session_get('multiuse_object_key', '')
|
multiuse_object_key = strategy.session_get('multiuse_object_key', '')
|
||||||
mobile_flow_otp = strategy.session_get('mobile_flow_otp')
|
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:
|
if user_profile is None or user_profile.is_mirror_dummy:
|
||||||
is_signup = strategy.session_get('is_signup') == '1'
|
is_signup = strategy.session_get('is_signup') == '1'
|
||||||
else:
|
else:
|
||||||
|
@ -1086,10 +1091,17 @@ def social_auth_finish(backend: Any,
|
||||||
#
|
#
|
||||||
# The next step is to call login_or_register_remote_user, but
|
# The next step is to call login_or_register_remote_user, but
|
||||||
# there are two code paths here because of an optimization to save
|
# 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:
|
if mobile_flow_otp or desktop_flow_otp:
|
||||||
# For mobile app authentication, login_or_register_remote_user
|
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
|
# will redirect to a special zulip:// URL that is handled by
|
||||||
# the app after a successful authentication; so we can
|
# the app after a successful authentication; so we can
|
||||||
# redirect directly from here, saving a round trip over what
|
# 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(
|
return login_or_register_remote_user(
|
||||||
strategy.request, email_address,
|
strategy.request, email_address,
|
||||||
user_profile, full_name,
|
user_profile, full_name,
|
||||||
mobile_flow_otp=mobile_flow_otp,
|
|
||||||
is_signup=is_signup,
|
is_signup=is_signup,
|
||||||
redirect_to=redirect_to,
|
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
|
# If this authentication code were executing on
|
||||||
|
@ -1263,7 +1275,7 @@ class GoogleAuthBackend(SocialAuthMixin, GoogleOAuth2):
|
||||||
@external_auth_method
|
@external_auth_method
|
||||||
class SAMLAuthBackend(SocialAuthMixin, SAMLAuth):
|
class SAMLAuthBackend(SocialAuthMixin, SAMLAuth):
|
||||||
auth_backend_name = "SAML"
|
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"]
|
"next", "is_signup"]
|
||||||
REDIS_EXPIRATION_SECONDS = 60 * 15
|
REDIS_EXPIRATION_SECONDS = 60 * 15
|
||||||
name = "saml"
|
name = "saml"
|
||||||
|
|
|
@ -964,7 +964,8 @@ if REGISTER_LINK_DISABLED is None:
|
||||||
# SOCIAL AUTHENTICATION SETTINGS
|
# 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_LOGIN_ERROR_URL = '/login/'
|
||||||
|
|
||||||
SOCIAL_AUTH_GITHUB_SECRET = get_secret('social_auth_github_secret')
|
SOCIAL_AUTH_GITHUB_SECRET = get_secret('social_auth_github_secret')
|
||||||
|
|
Loading…
Reference in New Issue