auth: Use the clipboard instead of zulip:// for desktop auth flow.

This does not rely on the desktop app being able to register for the
zulip:// scheme (which is problematic with, for example, the AppImage
format).

It also is a better interface for managing changes to the system,
since the implementation exists almost entirely in the server/webapp
project.

This provides a smoother user experience, where the user doesn't need
to do the paste step, when combined with
https://github.com/zulip/zulip-desktop/pull/943.

Fixes #13613.

Signed-off-by: Anders Kaseorg <anders@zulip.com>
This commit is contained in:
Anders Kaseorg 2020-04-24 21:49:19 -07:00 committed by Tim Abbott
parent 1f809f338b
commit a552c2e5f9
10 changed files with 172 additions and 48 deletions

View File

@ -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

View File

@ -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 \

View File

@ -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 \

View File

@ -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);
})();

View File

@ -0,0 +1,3 @@
const ClipboardJS = require("clipboard");
new ClipboardJS("#copy");
document.querySelector("#copy").focus();

View File

@ -0,0 +1,27 @@
{% extends "zerver/portico.html" %}
{% set entrypoint = "desktop-login" %}
{% block portico_content %}
<div class="flex">
<div class="desktop-redirect-box">
<h1>{% trans %}Finish desktop login{% endtrans %}</h1>
<div class="white-box">
<p>{% trans %}Use your web browser to finish logging in, then come back here to paste in your login token.{% endtrans %}</p>
<form id="form" action="blob:">
<input id="token" placeholder="{% trans %}Paste token here{% endtrans %}" />
<button id="submit" disabled class="btn btn-primary">{% trans %}Finish{% endtrans %}</button>
</form>
<p id="bad-token" hidden>
{% trans %}Incorrect token.{% endtrans %}
</p>
<p id="done" hidden>
{% trans %}Token accepted. Logging you in…{% endtrans %}
</p>
</div>
</div>
</div>
{% endblock %}

View File

@ -1,18 +1,17 @@
{% extends "zerver/portico.html" %}
{% block customhead %}
<meta http-equiv="Refresh" content="2; {{ desktop_url }}">
{% endblock %}
{% set entrypoint = "desktop-redirect" %}
{% block content %}
<div class="flex">
<div class="desktop-redirect-box">
<img class="avatar desktop-redirect-image" src="{{ realm_icon_url }}" alt=""/><br>
Redirecting to your Zulip app...
<div class="desktop-redirect-links">
<a id="desktop-redirect-link" href="{{ desktop_url }}">If you weren't redirected, click here.</a><br>
<a href="{{ browser_url }}">Or, continue in your browser.</a>
</div>
<p>{% trans %}Copy this login token and return to your Zulip app to finish logging in:{% endtrans %}</p>
<p>
<input id="desktop-data" value="{{ desktop_data }}" readonly />
<button id="copy" tabindex="0" class="btn btn-primary" data-clipboard-target="#desktop-data">{% trans %}Copy{% endtrans %}</button>
</p>
<p>{% trans %}You may then close this window.{% endtrans %}</p>
<p><a href="{{ browser_url }}">{% trans %}Or, continue in your browser.{% endtrans %}</a></p>
</div>
</div>
{% endblock %}

View File

@ -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",

View File

@ -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"<h1>Finish desktop login</h1>", 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)

View File

@ -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":