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 %}
+
+
+
+
+ {% trans %}Incorrect token.{% endtrans %}
+
+
+
+ {% trans %}Token accepted. Logging you in…{% 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":