mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-04 16:56:59 +01:00
added webgtk recaptcha
This commit is contained in:
1 parent
ed129f7c71
commit
cb67d97b0f
10 files changed
+407
-35
No files matched your search
@@ -30,3 +30,4 @@ gtk4 = { version = "0.9", features = ["v4_12"] }
|
||||
adw = { package = "libadwaita", version = "0.7", features = ["v1_5"] }
|
||||
async-channel = "2"
|
||||
exif = { version = "0.6", package = "kamadak-exif" }
|
||||
webkit6 = "0.4"
|
||||
@@ -11,6 +11,7 @@ use pages::shared::*;
|
||||
use pages::shared_by_me::*;
|
||||
use pages::status::*;
|
||||
use pages::trash::*;
|
||||
use pages::verify::*;
|
||||
use widgets::details::*;
|
||||
use widgets::share_dialog::*;
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use crate::*;
|
||||
use pdfs_core::proton_sdk::api::HumanVerificationCredential;
|
||||
|
||||
pub(crate) struct LoginState {
|
||||
// Login page.
|
||||
@@ -104,18 +105,35 @@ pub(crate) fn wire_login(ui: &Rc<Ui>) {
|
||||
|
||||
ui.login.login_button.set_sensitive(false);
|
||||
ui.login.login_status.set_text("Signing in…");
|
||||
let (rx, totp_req_rx) = spawn_login(username, password);
|
||||
let (rx, totp_req_rx, hv_req_rx) = spawn_login(username, password);
|
||||
|
||||
// Surface the 2FA dialog only if the SDK actually asks for a code (i.e.
|
||||
// the account has 2FA enabled). The worker blocks until the dialog feeds
|
||||
// back a code (or is cancelled, dropping the sender).
|
||||
//
|
||||
// Loops rather than awaiting once: a login gated behind a CAPTCHA is
|
||||
// restarted after verification, so a 2FA account is asked for a second,
|
||||
// unexpired code on the retry.
|
||||
let ui_2fa = ui.clone();
|
||||
glib::spawn_future_local(async move {
|
||||
if let Ok(code_tx) = totp_req_rx.recv().await {
|
||||
while let Ok(code_tx) = totp_req_rx.recv().await {
|
||||
prompt_2fa(&ui_2fa, code_tx);
|
||||
}
|
||||
});
|
||||
|
||||
// Same shape for human verification: only fires when the API gates the
|
||||
// sign-in, and the worker is blocked on the token until it does.
|
||||
let ui_hv = ui.clone();
|
||||
glib::spawn_future_local(async move {
|
||||
if let Ok((url, token_tx)) = hv_req_rx.recv().await {
|
||||
ui_hv
|
||||
.login
|
||||
.login_status
|
||||
.set_text("Complete the verification to continue…");
|
||||
prompt_human_verification(&ui_hv, &url, token_tx);
|
||||
}
|
||||
});
|
||||
|
||||
let ui = ui.clone();
|
||||
glib::spawn_future_local(async move {
|
||||
let result = rx
|
||||
@@ -155,9 +173,13 @@ pub(crate) fn spawn_login(
|
||||
) -> (
|
||||
async_channel::Receiver<Result<(), String>>,
|
||||
async_channel::Receiver<std::sync::mpsc::Sender<String>>,
|
||||
async_channel::Receiver<(String, std::sync::mpsc::Sender<String>)>,
|
||||
) {
|
||||
let (tx, rx) = async_channel::bounded(1);
|
||||
let (totp_req_tx, totp_req_rx) = async_channel::bounded(1);
|
||||
// Two, not one: a CAPTCHA-gated login restarts, so a 2FA account is asked
|
||||
// for a code on each attempt and a single slot would deadlock the worker.
|
||||
let (totp_req_tx, totp_req_rx) = async_channel::bounded(2);
|
||||
let (hv_req_tx, hv_req_rx) = async_channel::bounded(1);
|
||||
std::thread::spawn(move || {
|
||||
let rt = match tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
@@ -170,24 +192,42 @@ pub(crate) fn spawn_login(
|
||||
}
|
||||
};
|
||||
let result = rt.block_on(async move {
|
||||
auth::login(&username, &password, || {
|
||||
// 2FA required: hand a one-shot sender to the UI and block until
|
||||
// the dialog supplies the code. A dropped sender (cancelled
|
||||
// dialog) surfaces as a cancelled login.
|
||||
let (code_tx, code_rx) = std::sync::mpsc::channel::<String>();
|
||||
totp_req_tx
|
||||
.send_blocking(code_tx)
|
||||
.map_err(|_| pdfs_core::Error::Other("two-factor prompt closed".into()))?;
|
||||
code_rx
|
||||
.recv()
|
||||
.map_err(|_| pdfs_core::Error::Other("two-factor entry cancelled".into()))
|
||||
})
|
||||
auth::login_interactive(
|
||||
&username,
|
||||
&password,
|
||||
|| {
|
||||
// 2FA required: hand a one-shot sender to the UI and block
|
||||
// until the dialog supplies the code. A dropped sender
|
||||
// (cancelled dialog) surfaces as a cancelled login.
|
||||
let (code_tx, code_rx) = std::sync::mpsc::channel::<String>();
|
||||
totp_req_tx
|
||||
.send_blocking(code_tx)
|
||||
.map_err(|_| pdfs_core::Error::Other("two-factor prompt closed".into()))?;
|
||||
code_rx
|
||||
.recv()
|
||||
.map_err(|_| pdfs_core::Error::Other("two-factor entry cancelled".into()))
|
||||
},
|
||||
|hv| {
|
||||
// Gated: hand the UI the page to show, block on the token the
|
||||
// user earns by solving it.
|
||||
let (token_tx, token_rx) = std::sync::mpsc::channel::<String>();
|
||||
hv_req_tx
|
||||
.send_blocking((hv.verification_url(), token_tx))
|
||||
.map_err(|_| {
|
||||
pdfs_core::Error::Other("verification prompt closed".into())
|
||||
})?;
|
||||
let token = token_rx
|
||||
.recv()
|
||||
.map_err(|_| pdfs_core::Error::Other("verification cancelled".into()))?;
|
||||
Ok(HumanVerificationCredential::captcha(token))
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
});
|
||||
let _ = tx.send_blocking(result);
|
||||
});
|
||||
(rx, totp_req_rx)
|
||||
(rx, totp_req_rx, hv_req_rx)
|
||||
}
|
||||
|
||||
/// Show the lazy two-factor dialog and feed the entered code back to the waiting
|
||||
|
||||
@@ -8,3 +8,4 @@ pub(crate) mod shared;
|
||||
pub(crate) mod shared_by_me;
|
||||
pub(crate) mod status;
|
||||
pub(crate) mod trash;
|
||||
pub(crate) mod verify;
|
||||
@@ -0,0 +1,151 @@
|
||||
//! The human-verification (CAPTCHA) dialog.
|
||||
//!
|
||||
//! Proton gates logins it does not recognise — a new IP, a VPN exit — behind a
|
||||
//! CAPTCHA. The API answers the login with a challenge instead of a session, and
|
||||
//! expects the client to present Proton's hosted verification page, collect the
|
||||
//! token the user earns by solving it, and retry the login carrying that token.
|
||||
//!
|
||||
//! The page is hosted in a `WebKitWebView` rather than handed to the system
|
||||
//! browser because it reports completion by posting a message to its host, not
|
||||
//! by redirecting: a browser has nowhere to post it back to.
|
||||
|
||||
use crate::*;
|
||||
use webkit6::prelude::*;
|
||||
|
||||
/// Bridge the verification page's `postMessage` to a handler this side can read.
|
||||
///
|
||||
/// The page targets its embedding host (`window.parent`), which in a webview is
|
||||
/// the page itself — so nothing arrives unless the message is forwarded
|
||||
/// explicitly. Injecting at document start guarantees the listener is installed
|
||||
/// before the page can post anything.
|
||||
const BRIDGE: &str = r#"
|
||||
window.addEventListener('message', function (event) {
|
||||
try {
|
||||
window.webkit.messageHandlers.hv.postMessage(JSON.stringify(event.data));
|
||||
} catch (e) {}
|
||||
});
|
||||
"#;
|
||||
|
||||
/// Present the challenge and hand the solved token back through `token_tx`.
|
||||
///
|
||||
/// Closing the dialog without solving drops the sender, which the waiting login
|
||||
/// worker reads as a cancelled sign-in — the same contract as the 2FA prompt.
|
||||
pub(crate) fn prompt_human_verification(
|
||||
ui: &Rc<Ui>,
|
||||
url: &str,
|
||||
token_tx: std::sync::mpsc::Sender<String>,
|
||||
) {
|
||||
let content = webkit6::UserContentManager::new();
|
||||
content.add_script(&webkit6::UserScript::new(
|
||||
BRIDGE,
|
||||
webkit6::UserContentInjectedFrames::AllFrames,
|
||||
webkit6::UserScriptInjectionTime::Start,
|
||||
&[],
|
||||
&[],
|
||||
));
|
||||
// Registration is what makes `messageHandlers.hv` exist in the page; without
|
||||
// it the bridge above throws on every message.
|
||||
content.register_script_message_handler("hv", None);
|
||||
|
||||
let webview = webkit6::WebView::builder()
|
||||
.user_content_manager(&content)
|
||||
.vexpand(true)
|
||||
.hexpand(true)
|
||||
.build();
|
||||
|
||||
let dialog = adw::Dialog::builder()
|
||||
.title("Verification")
|
||||
.content_width(420)
|
||||
.content_height(560)
|
||||
.build();
|
||||
|
||||
let header = adw::HeaderBar::new();
|
||||
let body = gtk4::Box::new(gtk4::Orientation::Vertical, 0);
|
||||
body.append(&header);
|
||||
body.append(&webview);
|
||||
dialog.set_child(Some(&body));
|
||||
|
||||
// Taken on the first success *or* on close, so the token is sent at most
|
||||
// once and a cancelled dialog always drops the sender.
|
||||
let token_tx = Rc::new(RefCell::new(Some(token_tx)));
|
||||
|
||||
let tx = token_tx.clone();
|
||||
let dlg = dialog.clone();
|
||||
content.connect_script_message_received(Some("hv"), move |_, value| {
|
||||
let Some(token) = extract_token(&value.to_str()) else {
|
||||
// Every `postMessage` on the page reaches here, most of them the
|
||||
// verification app's own chatter. Anything that is not a completion
|
||||
// is simply not ours.
|
||||
return;
|
||||
};
|
||||
if let Some(tx) = tx.borrow_mut().take() {
|
||||
let _ = tx.send(token);
|
||||
}
|
||||
dlg.close();
|
||||
});
|
||||
|
||||
// Covers the close button, Escape, and the user giving up.
|
||||
dialog.connect_closed(move |_| {
|
||||
token_tx.borrow_mut().take();
|
||||
});
|
||||
|
||||
webview.load_uri(url);
|
||||
|
||||
let parent = ui.login.login_button.root().and_downcast::<gtk4::Window>();
|
||||
// Returns immediately; the login worker stays blocked on the channel until
|
||||
// the handler above sends, or the dialog closes and drops the sender.
|
||||
dialog.present(parent.as_ref());
|
||||
}
|
||||
|
||||
/// Pull the verification token out of a message posted by the verification page.
|
||||
///
|
||||
/// Returns `None` for anything that is not a completion message. The page posts
|
||||
/// a good deal besides — resize requests, readiness pings — and treating an
|
||||
/// unrecognised shape as success would hand the API an empty token and fail the
|
||||
/// login with a confusing error.
|
||||
fn extract_token(raw: &str) -> Option<String> {
|
||||
let value: serde_json::Value = serde_json::from_str(raw).ok()?;
|
||||
// The payload is nested under `payload` and the message names itself in
|
||||
// `type`; both spellings of the success type have shipped.
|
||||
let kind = value.get("type")?.as_str()?;
|
||||
if !matches!(
|
||||
kind,
|
||||
"HUMAN_VERIFICATION_SUCCESS" | "human_verification_success"
|
||||
) {
|
||||
return None;
|
||||
}
|
||||
let payload = value.get("payload")?;
|
||||
let token = payload.get("token")?.as_str()?;
|
||||
if token.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(token.to_owned())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::extract_token;
|
||||
|
||||
#[test]
|
||||
fn a_completion_message_yields_its_token() {
|
||||
let raw =
|
||||
r#"{"type":"HUMAN_VERIFICATION_SUCCESS","payload":{"token":"tok-1","type":"captcha"}}"#;
|
||||
assert_eq!(extract_token(raw).as_deref(), Some("tok-1"));
|
||||
}
|
||||
|
||||
/// The page posts plenty that is not a completion; none of it may be
|
||||
/// mistaken for one, or the login retries with a garbage token.
|
||||
#[test]
|
||||
fn unrelated_messages_are_ignored() {
|
||||
for raw in [
|
||||
r#"{"type":"resize","payload":{"height":400}}"#,
|
||||
r#"{"type":"HUMAN_VERIFICATION_SUCCESS"}"#,
|
||||
r#"{"payload":{"token":"tok"}}"#,
|
||||
r#"{"type":"HUMAN_VERIFICATION_SUCCESS","payload":{"token":""}}"#,
|
||||
"not json at all",
|
||||
"",
|
||||
] {
|
||||
assert!(extract_token(raw).is_none(), "accepted: {raw}");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user