diff --git a/requirements/common.in b/requirements/common.in index 6c50769355..65d12615e6 100644 --- a/requirements/common.in +++ b/requirements/common.in @@ -145,6 +145,9 @@ py3dns social-auth-app-django social-auth-core[azuread,saml] +# For encrypting a login token to the desktop app +cryptography + # Needed for messages' rendered content parsing in push notifications. lxml diff --git a/requirements/dev.txt b/requirements/dev.txt index a509f8006b..2412aa024e 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -230,7 +230,7 @@ cryptography==2.9 \ --hash=sha256:d1bf5a1a0d60c7f9a78e448adcb99aa101f3f9588b16708044638881be15d6bc \ --hash=sha256:ed1d0760c7e46436ec90834d6f10477ff09475c692ed1695329d324b2c5cd547 \ --hash=sha256:ef9a55013676907df6c9d7dd943eb1770d014f68beaa7e73250fb43c759f4585 \ - # via apns2, moto, pyopenssl, requests, scrapy, service-identity, social-auth-core, sshpubkeys + # via -r requirements/common.in, apns2, moto, pyopenssl, requests, scrapy, service-identity, social-auth-core, sshpubkeys cssselect2==0.3.0 \ --hash=sha256:5c2716f06b5de93f701d5755a9666f2ee22cbcd8b4da8adddfc30095ffea3abc \ --hash=sha256:97d7d4234f846f9996d838964d38e13b45541c18143bc55cf00e4bc1281ace76 \ diff --git a/requirements/prod.txt b/requirements/prod.txt index 3925a5ee30..d61967618d 100644 --- a/requirements/prod.txt +++ b/requirements/prod.txt @@ -143,7 +143,7 @@ cryptography==2.9 \ --hash=sha256:d1bf5a1a0d60c7f9a78e448adcb99aa101f3f9588b16708044638881be15d6bc \ --hash=sha256:ed1d0760c7e46436ec90834d6f10477ff09475c692ed1695329d324b2c5cd547 \ --hash=sha256:ef9a55013676907df6c9d7dd943eb1770d014f68beaa7e73250fb43c759f4585 \ - # via apns2, pyopenssl, requests, social-auth-core + # via -r requirements/common.in, apns2, pyopenssl, requests, social-auth-core cssselect==1.1.0 \ --hash=sha256:f612ee47b749c877ebae5bb77035d8f4202c6ad0f0fc1271b3c18ad6c4468ecf \ --hash=sha256:f95f8dedd925fd8f54edb3d2dfb44c190d9d18512377d3c1e2388d16126879bc \ diff --git a/static/js/portico/desktop-login.js b/static/js/portico/desktop-login.js new file mode 100644 index 0000000000..5e1d3d8997 --- /dev/null +++ b/static/js/portico/desktop-login.js @@ -0,0 +1,63 @@ +document.querySelector("#form").addEventListener("submit", () => { + document.querySelector("#bad-token").hidden = false; +}); +document.querySelector("#token").focus(); + +async function decrypt_manual() { + const key = await crypto.subtle.generateKey({ name: "AES-GCM", length: 256 }, true, [ + "decrypt", + ]); + return { + key: new Uint8Array(await crypto.subtle.exportKey("raw", key)), + pasted: new Promise((resolve) => { + const tokenElement = document.querySelector("#token"); + tokenElement.addEventListener("input", async () => { + document.querySelector("#bad-token").hidden = true; + document.querySelector("#submit").disabled = tokenElement.value === ""; + try { + const data = new Uint8Array( + tokenElement.value.match(/../g).map((b) => parseInt(b, 16)) + ); + const iv = data.slice(0, 12); + const ciphertext = data.slice(12); + const plaintext = await crypto.subtle.decrypt( + { name: "AES-GCM", iv }, + key, + ciphertext + ); + resolve(new TextDecoder().decode(plaintext)); + } catch { + // Ignore all parsing and decryption failures. + } + }); + }), + }; +} + +(async () => { + // Sufficiently new versions of the desktop app provide the + // electron_bridge.decrypt_clipboard API, which returns AES-GCM encryption + // key and a promise; as soon as something encrypted to that key is copied + // to the clipboard, the app decrypts it and resolves the promise to the + // plaintext. This lets us skip the manual paste step. + const { key, pasted } = + window.electron_bridge && window.electron_bridge.decrypt_clipboard + ? window.electron_bridge.decrypt_clipboard(1) + : await decrypt_manual(); + + const keyHex = Array.from(key, (b) => b.toString(16).padStart(2, "0")).join(""); + window.open( + new URL( + (window.location.search ? window.location.search + "&" : "?") + + "desktop_flow_otp=" + + encodeURIComponent(keyHex), + window.location.href + ), + "_blank" + ); + + const token = await pasted; + document.querySelector("#form").hidden = true; + document.querySelector("#done").hidden = false; + window.location.href = "/accounts/login/subdomain/" + encodeURIComponent(token); +})(); diff --git a/static/js/portico/desktop-redirect.js b/static/js/portico/desktop-redirect.js new file mode 100644 index 0000000000..247076db21 --- /dev/null +++ b/static/js/portico/desktop-redirect.js @@ -0,0 +1,3 @@ +const ClipboardJS = require("clipboard"); +new ClipboardJS("#copy"); +document.querySelector("#copy").focus(); diff --git a/templates/zerver/desktop_login.html b/templates/zerver/desktop_login.html new file mode 100644 index 0000000000..cb63826f47 --- /dev/null +++ b/templates/zerver/desktop_login.html @@ -0,0 +1,27 @@ +{% extends "zerver/portico.html" %} +{% set entrypoint = "desktop-login" %} + +{% block portico_content %} +
+
+

{% trans %}Finish desktop login{% endtrans %}

+ +
+

{% trans %}Use your web browser to finish logging in, then come back here to paste in your login token.{% endtrans %}

+ +
+ + +
+ + + + +
+
+
+{% endblock %} diff --git a/templates/zerver/desktop_redirect.html b/templates/zerver/desktop_redirect.html index abdf538eca..698cdd7de3 100644 --- a/templates/zerver/desktop_redirect.html +++ b/templates/zerver/desktop_redirect.html @@ -1,18 +1,17 @@ {% extends "zerver/portico.html" %} - -{% block customhead %} - -{% endblock %} +{% set entrypoint = "desktop-redirect" %} {% block content %}

- Redirecting to your Zulip app... - +

{% trans %}Copy this login token and return to your Zulip app to finish logging in:{% endtrans %}

+

+ + +

+

{% trans %}You may then close this window.{% endtrans %}

+

{% trans %}Or, continue in your browser.{% endtrans %}

{% endblock %} diff --git a/tools/webpack.assets.json b/tools/webpack.assets.json index 710b88fcca..4ae9918ade 100644 --- a/tools/webpack.assets.json +++ b/tools/webpack.assets.json @@ -101,6 +101,14 @@ "./static/js/bundles/portico.js", "./static/js/portico/dev-login.js" ], + "desktop-login": [ + "./static/js/bundles/portico.js", + "./static/js/portico/desktop-login.js" + ], + "desktop-redirect": [ + "./static/js/bundles/portico.js", + "./static/js/portico/desktop-redirect.js" + ], "integrations-dev-panel": [ "./static/js/bundles/portico.js", "./static/js/portico/integrations_dev_panel.js", diff --git a/zerver/tests/test_auth_backends.py b/zerver/tests/test_auth_backends.py index a9203cbb4e..5f0af05deb 100644 --- a/zerver/tests/test_auth_backends.py +++ b/zerver/tests/test_auth_backends.py @@ -1,4 +1,5 @@ from bs4 import BeautifulSoup +from cryptography.hazmat.primitives.ciphers.aead import AESGCM from django.conf import settings from django.contrib.auth import authenticate from django.core import mail @@ -606,24 +607,19 @@ class CheckPasswordStrengthTest(ZulipTestCase): self.assertTrue(check_password_strength('f657gdGGk9')) class DesktopFlowTestingLib(ZulipTestCase): + def verify_desktop_flow_app_page(self, response: HttpResponse) -> None: + self.assertEqual(response.status_code, 200) + self.assertIn(b"

Finish desktop login

", response.content) + def verify_desktop_flow_end_page(self, response: HttpResponse, email: str, desktop_flow_otp: str) -> None: self.assertEqual(response.status_code, 200) soup = BeautifulSoup(response.content, "html.parser") - links = [a['href'] for a in soup.find_all('a', href=True)] - self.assert_length(links, 2) - for url in links: - if url.startswith("zulip://"): - desktop_app_url = url - else: - browser_url = url - meta_refresh_content = soup.find('meta', {'http-equiv': lambda value: value == "Refresh"})['content'] - auto_redirect_url = meta_refresh_content.split('; ')[1] + desktop_data = soup.find("input", value=True)["value"] + browser_url = soup.find("a", href=True)["href"] - self.assertEqual(auto_redirect_url, desktop_app_url) - - decrypted_key = self.verify_desktop_app_url_and_return_key(desktop_app_url, email, desktop_flow_otp) + decrypted_key = self.verify_desktop_data_and_return_key(desktop_data, desktop_flow_otp) self.assertEqual(browser_url, 'http://zulip.testserver/accounts/login/subdomain/%s' % (decrypted_key,)) result = self.client_get(browser_url) @@ -632,16 +628,12 @@ class DesktopFlowTestingLib(ZulipTestCase): user_profile = get_user_by_delivery_email(email, realm) self.assert_logged_in_user_id(user_profile.id) - def verify_desktop_app_url_and_return_key(self, url: str, email: str, desktop_flow_otp: str) -> str: - parsed_url = urllib.parse.urlparse(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) - return decrypted_key + def verify_desktop_data_and_return_key(self, desktop_data: str, desktop_flow_otp: str) -> str: + key = bytes.fromhex(desktop_flow_otp) + data = bytes.fromhex(desktop_data) + iv = data[:12] + ciphertext = data[12:] + return AESGCM(key).decrypt(iv, ciphertext, b"").decode() class SocialAuthBase(DesktopFlowTestingLib, ZulipTestCase): """This is a base class for testing social-auth backends. These @@ -679,15 +671,18 @@ class SocialAuthBase(DesktopFlowTestingLib, ZulipTestCase): **extra_data: Any) -> None: pass - 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: bool=False, - next: str='', - multiuse_object_key: str='', - alternative_start_url: Optional[str]=None - ) -> Tuple[str, Dict[str, Any]]: + 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: bool=False, + next: str='', + multiuse_object_key: str='', + alternative_start_url: Optional[str]=None, + *, + user_agent: Optional[str]=None, + ) -> Tuple[str, Dict[str, Any]]: url = self.LOGIN_URL if alternative_start_url is not None: url = alternative_start_url @@ -707,6 +702,8 @@ class SocialAuthBase(DesktopFlowTestingLib, ZulipTestCase): params['multiuse_object_key'] = multiuse_object_key if len(params) > 0: url += "?%s" % (urllib.parse.urlencode(params),) + if user_agent is not None: + headers['HTTP_USER_AGENT'] = user_agent return url, headers @@ -719,10 +716,12 @@ class SocialAuthBase(DesktopFlowTestingLib, ZulipTestCase): multiuse_object_key: str='', expect_choose_email_screen: bool=False, alternative_start_url: Optional[str]=None, + user_agent: Optional[str]=None, **extra_data: Any) -> HttpResponse: url, headers = self.prepare_login_url_and_headers( subdomain, mobile_flow_otp, desktop_flow_otp, is_signup, next, - multiuse_object_key, alternative_start_url + multiuse_object_key, alternative_start_url, + user_agent=user_agent, ) result = self.client_get(url, **headers) @@ -966,6 +965,14 @@ class SocialAuthBase(DesktopFlowTestingLib, ZulipTestCase): 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=False, + desktop_flow_otp=desktop_flow_otp, + user_agent="ZulipElectron/5.0.0", + ) + self.verify_desktop_flow_app_page(result) result = self.social_auth_test(account_data_dict, subdomain='zulip', expect_choose_email_screen=False, desktop_flow_otp=desktop_flow_otp) @@ -1363,9 +1370,16 @@ class SAMLAuthBackendTest(SocialAuthBase): is_signup: bool=False, next: str='', multiuse_object_key: str='', + user_agent: Optional[str]=None, **extra_data: Any) -> HttpResponse: url, headers = self.prepare_login_url_and_headers( - subdomain, mobile_flow_otp, desktop_flow_otp, is_signup, next, multiuse_object_key + subdomain, + mobile_flow_otp, + desktop_flow_otp, + is_signup, + next, + multiuse_object_key, + user_agent=user_agent, ) result = self.client_get(url, **headers) diff --git a/zerver/views/auth.py b/zerver/views/auth.py index 7c9d31427f..f68ce4357a 100644 --- a/zerver/views/auth.py +++ b/zerver/views/auth.py @@ -1,3 +1,5 @@ +import os +from cryptography.hazmat.primitives.ciphers.aead import AESGCM from django.forms import Form from django.conf import settings from django.contrib.auth import authenticate @@ -260,7 +262,7 @@ def login_or_register_remote_user(request: HttpRequest, result: ExternalAuthResu def finish_desktop_flow(request: HttpRequest, user_profile: UserProfile, otp: str) -> HttpResponse: """ - The desktop otp flow returns to the app (through a zulip:// redirect) + The desktop otp flow returns to the app (through the clipboard) 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 ExternalAuthResult.LOGIN_KEY_EXPIRATION_SECONDS @@ -269,10 +271,11 @@ def finish_desktop_flow(request: HttpRequest, user_profile: UserProfile, """ result = ExternalAuthResult(user_profile=user_profile) token = result.store_data() - response = create_response_for_otp_flow(token, otp, user_profile, - encrypted_key_field_name='otp_encrypted_login_key') + key = bytes.fromhex(otp) + iv = os.urandom(12) + desktop_data = (iv + AESGCM(key).encrypt(iv, token.encode(), b"")).hex() browser_url = user_profile.realm.uri + reverse('zerver.views.auth.log_into_subdomain', args=[token]) - context = {'desktop_url': response['Location'], + context = {'desktop_data': desktop_data, 'browser_url': browser_url, 'realm_icon_url': realm_icon_url(user_profile.realm)} return render(request, 'zerver/desktop_redirect.html', context=context) @@ -444,6 +447,10 @@ def oauth_redirect_to_root(request: HttpRequest, url: str, def start_social_login(request: HttpRequest, backend: str, extra_arg: Optional[str]=None ) -> HttpResponse: + user_agent = parse_user_agent(request.META.get("HTTP_USER_AGENT", "Missing User-Agent")) + if user_agent["name"] == "ZulipElectron": + return render(request, "zerver/desktop_login.html") + backend_url = reverse('social:begin', args=[backend]) extra_url_params: Dict[str, str] = {} if backend == "saml":