mirror of https://github.com/zulip/zulip.git
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:
parent
1f809f338b
commit
a552c2e5f9
|
@ -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
|
||||
|
||||
|
|
|
@ -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 \
|
||||
|
|
|
@ -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 \
|
||||
|
|
|
@ -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);
|
||||
})();
|
|
@ -0,0 +1,3 @@
|
|||
const ClipboardJS = require("clipboard");
|
||||
new ClipboardJS("#copy");
|
||||
document.querySelector("#copy").focus();
|
|
@ -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 %}
|
|
@ -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 %}
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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,14 +671,17 @@ class SocialAuthBase(DesktopFlowTestingLib, ZulipTestCase):
|
|||
**extra_data: Any) -> None:
|
||||
pass
|
||||
|
||||
def prepare_login_url_and_headers(self,
|
||||
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
|
||||
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:
|
||||
|
@ -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)
|
||||
|
|
|
@ -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":
|
||||
|
|
Loading…
Reference in New Issue