diff --git a/.gitignore b/.gitignore index ff8b7cd..7e0462a 100644 --- a/.gitignore +++ b/.gitignore @@ -33,3 +33,5 @@ target/ .ionide # End of https://www.toptal.com/developers/gitignore/api/rust,visualstudiocode + +*.profraw diff --git a/.vscode/settings.json b/.vscode/settings.json index 3df3c9a..8820bcc 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -9,6 +9,7 @@ "consts", "devicename", "distro", + "gpauth", "gpclient", "gpcommon", "gpconf", @@ -27,6 +28,7 @@ "prelogonuserauthcookie", "repr", "reqwest", + "roxmltree", "rustc", "servercert", "shlex", diff --git a/Cargo.lock b/Cargo.lock index 4ba25d7..72787b4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -148,6 +148,7 @@ version = "0.1.0" dependencies = [ "aes-gcm", "anyhow", + "gpauth", "gpcommon", "hex", "keyring", @@ -167,6 +168,16 @@ dependencies = [ "whoami", ] +[[package]] +name = "assert-json-diff" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "async-broadcast" version = "0.5.1" @@ -319,22 +330,6 @@ version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1181e1e0d1fce796a03db1ae795d67167da795f9cf4a39c37589e85ef57f26d3" -[[package]] -name = "attohttpc" -version = "0.22.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fcf00bc6d5abb29b5f97e3c61a90b6d3caa12f3faf897d4a3e3607c050a35a7" -dependencies = [ - "flate2", - "http", - "log", - "native-tls", - "serde", - "serde_json", - "serde_urlencoded", - "url", -] - [[package]] name = "atty" version = "0.2.14" @@ -486,6 +481,9 @@ name = "bytes" version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "89b2fd2a0dcf38d7971e2194b6b6eebab45ae01067456a7fd93d5547a61b70be" +dependencies = [ + "serde", +] [[package]] name = "cairo-rs" @@ -681,6 +679,17 @@ dependencies = [ "winapi", ] +[[package]] +name = "colored" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2674ec482fbc38012cf31e6c42ba0177b431a0cb6f15fe40efa5aab1bda516f6" +dependencies = [ + "is-terminal", + "lazy_static", + "windows-sys 0.48.0", +] + [[package]] name = "combine" version = "4.6.6" @@ -935,6 +944,15 @@ dependencies = [ "subtle", ] +[[package]] +name = "directories" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a49173b84e034382284f27f1af4dcbbd231ffa358c0fe316541a7337f376a35" +dependencies = [ + "dirs-sys", +] + [[package]] name = "dirs-next" version = "2.0.0" @@ -945,6 +963,18 @@ dependencies = [ "dirs-sys-next", ] +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.48.0", +] + [[package]] name = "dirs-sys-next" version = "0.1.2" @@ -1032,6 +1062,12 @@ dependencies = [ "syn 2.0.16", ] +[[package]] +name = "equivalent" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" + [[package]] name = "errno" version = "0.3.1" @@ -1080,7 +1116,7 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9f0c14694cbd524c8720dd69b0e3179344f04ebb5f90f2e4a440c6ea3b2f1ee" dependencies = [ - "colored", + "colored 1.9.3", "log", ] @@ -1156,6 +1192,21 @@ dependencies = [ "new_debug_unreachable", ] +[[package]] +name = "futures" +version = "0.3.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13e2792b0ff0340399d58445b88fd9770e3489eff258a4cbc1523418f12abf84" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + [[package]] name = "futures-channel" version = "0.3.26" @@ -1163,6 +1214,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2e5317663a9089767a1ec00a487df42e0ca174b61b4483213ac24448e4664df5" dependencies = [ "futures-core", + "futures-sink", ] [[package]] @@ -1232,6 +1284,7 @@ version = "0.3.26" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c1d6de3acfef38d2be4b1f543f553131788603495be83da675e180c8d6b7bd1" dependencies = [ + "futures-channel", "futures-core", "futures-io", "futures-macro", @@ -1498,13 +1551,35 @@ dependencies = [ "system-deps 6.0.3", ] +[[package]] +name = "gpauth" +version = "0.1.0" +dependencies = [ + "anyhow", + "clap", + "directories", + "fern", + "gpcommon", + "humantime", + "log", + "regex", + "reqwest", + "serde", + "serde_json", + "tokio", + "webkit2gtk", + "wry", +] + [[package]] name = "gpclient" version = "0.1.0" dependencies = [ "anyhow", "clap", + "gpauth", "gpcommon", + "libc", "reqwest", "tokio", "url", @@ -1516,6 +1591,7 @@ version = "0.1.0" dependencies = [ "anyhow", "async-trait", + "base64 0.21.0", "bytes", "cc", "configparser", @@ -1523,7 +1599,10 @@ dependencies = [ "is_executable", "lexopt", "log", + "mockito", + "reqwest", "ring", + "roxmltree", "serde", "serde_json", "shlex", @@ -1611,7 +1690,7 @@ dependencies = [ "futures-sink", "futures-util", "http", - "indexmap", + "indexmap 1.9.2", "slab", "tokio", "tokio-util", @@ -1624,6 +1703,12 @@ version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +[[package]] +name = "hashbrown" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c6201b9ff9fd90a5a3bac2e56a830d0caa509576f0e503818ee82c181b3437a" + [[package]] name = "heck" version = "0.3.3" @@ -1871,7 +1956,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1885e79c1fc4b10f0e172c475f458b7f7b93061064d98c3293e98c5ba0c8b399" dependencies = [ "autocfg", - "hashbrown", + "hashbrown 0.12.3", + "serde", +] + +[[package]] +name = "indexmap" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5477fe2230a79769d8dc68e0eabf5437907c0457a5614a9e8dddb67f65eb65d" +dependencies = [ + "equivalent", + "hashbrown 0.14.0", "serde", ] @@ -1919,6 +2015,17 @@ version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28b29a3cd74f0f4598934efe3aeba42bae0eb4680554128851ebbecb02af14e6" +[[package]] +name = "is-terminal" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb0889898416213fab133e1d33a0e5858a48177452750691bde3666d0fdbaf8b" +dependencies = [ + "hermit-abi 0.3.1", + "rustix 0.38.8", + "windows-sys 0.48.0", +] + [[package]] name = "is_executable" version = "1.0.1" @@ -2209,6 +2316,26 @@ dependencies = [ "windows-sys 0.42.0", ] +[[package]] +name = "mockito" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09c762b6267c4593555bb38f1df19e9318985bc4de60b5e8462890856a9a5b4c" +dependencies = [ + "assert-json-diff", + "colored 2.0.4", + "futures", + "hyper", + "lazy_static", + "log", + "rand 0.8.5", + "regex", + "serde_json", + "serde_urlencoded", + "similar", + "tokio", +] + [[package]] name = "native-tls" version = "0.2.11" @@ -2501,6 +2628,12 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + [[package]] name = "ordered-stream" version = "0.2.0" @@ -2727,7 +2860,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9469799ca90293a376f68f6fcb8f11990d9cff55602cfba0ba83893c973a7f46" dependencies = [ "base64 0.21.0", - "indexmap", + "indexmap 1.9.2", "line-wrap", "quick-xml", "serde", @@ -3027,10 +3160,12 @@ dependencies = [ "serde_urlencoded", "tokio", "tokio-native-tls", + "tokio-util", "tower-service", "url", "wasm-bindgen", "wasm-bindgen-futures", + "wasm-streams", "web-sys", "winreg 0.50.0", ] @@ -3050,6 +3185,15 @@ dependencies = [ "winapi", ] +[[package]] +name = "roxmltree" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8f595a457b6b8c6cda66a48503e92ee8d19342f905948f29c383200ec9eb1d8" +dependencies = [ + "xmlparser", +] + [[package]] name = "rustc_version" version = "0.3.3" @@ -3297,14 +3441,15 @@ dependencies = [ [[package]] name = "serde_with" -version = "2.3.3" +version = "3.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07ff71d2c147a7b57362cead5e22f772cd52f6ab31cfcd9edcd7f6aeb2a0afbe" +checksum = "1ca3b16a3d82c4088f343b7480a93550b3eabe1a358569c2dfe38bbcead07237" dependencies = [ - "base64 0.13.1", + "base64 0.21.0", "chrono", "hex", - "indexmap", + "indexmap 1.9.2", + "indexmap 2.0.0", "serde", "serde_json", "serde_with_macros", @@ -3313,9 +3458,9 @@ dependencies = [ [[package]] name = "serde_with_macros" -version = "2.3.3" +version = "3.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "881b6f881b17d13214e5d494c939ebab463d01264ce1811e9d4ac3a882e7695f" +checksum = "2e6be15c453eb305019bfa438b1593c731f36a289a7853f7707ee29e870b3b3c" dependencies = [ "darling", "proc-macro2", @@ -3411,6 +3556,12 @@ dependencies = [ "libc", ] +[[package]] +name = "similar" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "420acb44afdae038210c99e69aae24109f32f15500aa708e81d46c9f29d55fcf" + [[package]] name = "siphasher" version = "0.3.10" @@ -3557,6 +3708,19 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "sys-locale" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8a11bd9c338fdba09f7881ab41551932ad42e405f61d01e8406baea71c07aee" +dependencies = [ + "js-sys", + "libc", + "wasm-bindgen", + "web-sys", + "windows-sys 0.45.0", +] + [[package]] name = "system-deps" version = "5.0.0" @@ -3654,12 +3818,12 @@ dependencies = [ [[package]] name = "tauri" -version = "1.3.0" +version = "1.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d42ba3a2e8556722f31336a0750c10dbb6a81396a1c452977f515da83f69f842" +checksum = "7fbe522898e35407a8e60dc3870f7579fea2fc262a6a6072eccdd37ae1e1d91e" dependencies = [ "anyhow", - "attohttpc", + "bytes", "cocoa", "data-url", "dirs-next", @@ -3681,12 +3845,14 @@ dependencies = [ "rand 0.8.5", "raw-window-handle", "regex", + "reqwest", "semver 1.0.16", "serde", "serde_json", "serde_repr", "serialize-to-javascript", "state", + "sys-locale", "tar", "tauri-macros", "tauri-runtime", @@ -3704,9 +3870,9 @@ dependencies = [ [[package]] name = "tauri-build" -version = "1.3.0" +version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "929b3bd1248afc07b63e33a6a53c3f82c32d0b0a5e216e4530e94c467e019389" +checksum = "7d2edd6a259b5591c8efdeb9d5702cb53515b82a6affebd55c7fd6d3a27b7d1b" dependencies = [ "anyhow", "cargo_toml", @@ -3717,14 +3883,13 @@ dependencies = [ "serde_json", "tauri-utils", "tauri-winres", - "winnow", ] [[package]] name = "tauri-codegen" -version = "1.3.0" +version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5a2105f807c6f50b2fa2ce5abd62ef207bc6f14c9fcc6b8caec437f6fb13bde" +checksum = "54ad2d49fdeab4a08717f5b49a163bdc72efc3b1950b6758245fcde79b645e1a" dependencies = [ "base64 0.21.0", "brotli", @@ -3748,9 +3913,9 @@ dependencies = [ [[package]] name = "tauri-macros" -version = "1.3.0" +version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8784cfe6f5444097e93c69107d1ac5e8f13d02850efa8d8f2a40fe79674cef46" +checksum = "8eb12a2454e747896929338d93b0642144bb51e0dddbb36e579035731f0d76b7" dependencies = [ "heck 0.4.1", "proc-macro2", @@ -3789,9 +3954,9 @@ dependencies = [ [[package]] name = "tauri-runtime" -version = "0.13.0" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3b80ea3fcd5fefb60739a3b577b277e8fc30434538a2f5bba82ad7d4368c422" +checksum = "108683199cb18f96d2d4134187bb789964143c845d2d154848dda209191fd769" dependencies = [ "gtk", "http", @@ -3810,9 +3975,9 @@ dependencies = [ [[package]] name = "tauri-runtime-wry" -version = "0.13.0" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1c396950b1ba06aee1b4ffe6c7cd305ff433ca0e30acbc5fa1a2f92a4ce70f1" +checksum = "0b7aa256a1407a3a091b5d843eccc1a5042289baf0a43d1179d9f0fcfea37c1b" dependencies = [ "cocoa", "gtk", @@ -3830,12 +3995,13 @@ dependencies = [ [[package]] name = "tauri-utils" -version = "1.3.0" +version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a6f9c2dafef5cbcf52926af57ce9561bd33bb41d7394f8bb849c0330260d864" +checksum = "03fc02bb6072bb397e1d473c6f76c953cda48b4a2d0cce605df284aa74a12e84" dependencies = [ "brotli", "ctor", + "dunce", "glob", "heck 0.4.1", "html5ever", @@ -4044,7 +4210,7 @@ version = "0.19.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "239410c8609e8125456927e6707163a3b1fdb40561e4b803bc041f466ccfdc13" dependencies = [ - "indexmap", + "indexmap 1.9.2", "serde", "serde_spanned", "toml_datetime", @@ -4423,6 +4589,19 @@ version = "0.2.84" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0046fef7e28c3804e5e38bfa31ea2a0f73905319b677e57ebe37e49358989b5d" +[[package]] +name = "wasm-streams" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4609d447824375f43e1ffbc051b50ad8f4b3ae8219680c94452ea05eb240ac7" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "web-sys" version = "0.3.61" @@ -4907,6 +5086,12 @@ dependencies = [ "winapi", ] +[[package]] +name = "xmlparser" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d25c75bf9ea12c4040a97f829154768bbbce366287e2dc044af160cd79a13fd" + [[package]] name = "zbus" version = "3.14.1" diff --git a/Cargo.toml b/Cargo.toml index ae4a4e7..16c7a2b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,16 +12,22 @@ opt-level = "z" [workspace.dependencies] anyhow = "1.0" async-trait = "0.1" +base64 = "0.21" bytes = "1.0" clap = "4.4.2" configparser = "3.0" data-encoding = "2.3" +directories="5" +fern = "0.6" +humantime = "2.1" is_executable = "1.0" lexopt = "0.3.0" log = "0.4" +mockito = "1.1" regex = "1" reqwest = "0.11" ring = "0.16" +roxmltree = "0.18" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" shlex = "1.0" diff --git a/gpauth/Cargo.toml b/gpauth/Cargo.toml new file mode 100644 index 0000000..a65c6da --- /dev/null +++ b/gpauth/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "gpauth" +version.workspace = true +edition = "2021" + +[dependencies] +gpcommon = { path = "../gpcommon" } +anyhow.workspace = true +clap.workspace = true +directories.workspace = true +fern.workspace = true +humantime.workspace = true +log.workspace = true +regex.workspace = true +reqwest.workspace = true +serde_json.workspace = true +serde.workspace = true +tokio.workspace = true +webkit2gtk = "0.18.2" +wry = "0.24" + +[dev-dependencies] diff --git a/gpauth/src/auth_window.rs b/gpauth/src/auth_window.rs new file mode 100644 index 0000000..b961ff2 --- /dev/null +++ b/gpauth/src/auth_window.rs @@ -0,0 +1,395 @@ +use directories::ProjectDirs; +use gpcommon::portal::{Portal, Prelogin, SamlPrelogin}; +use log::{info, warn}; +use regex::Regex; +use serde::Serialize; +use std::{borrow::Cow, cell::RefCell, path::PathBuf, rc::Rc}; +use tokio::sync::mpsc; +use webkit2gtk::{ + gio::Cancellable, glib::GString, LoadEvent, URIResponse, URIResponseExt, WebResource, + WebResourceExt, WebViewExt, +}; +use wry::{ + application::{ + event::{Event, StartCause, WindowEvent}, + event_loop::{ControlFlow, EventLoop, EventLoopProxy}, + platform::run_return::EventLoopExtRunReturn, + window::WindowBuilder, + }, + webview::{WebContext, WebView, WebViewBuilder, WebviewExtUnix}, +}; + +enum UserEvent { + AuthFailed, + AuthSuccess(SamlAuthData), + AuthRequest(String), + Exit, +} + +enum AuthEvent { + FetchAuthRequest, + Exit, +} + +#[derive(Debug, Serialize)] +pub struct SamlAuthData { + username: String, + prelogin_cookie: Option, + portal_userauthcookie: Option, +} + +impl SamlAuthData { + fn check( + username: &Option, + prelogin_cookie: &Option, + portal_userauthcookie: &Option, + ) -> bool { + let username_valid = username + .as_ref() + .is_some_and(|username| !username.is_empty()); + let prelogin_cookie_valid = prelogin_cookie.as_ref().is_some_and(|val| val.len() > 5); + let portal_userauthcookie_valid = portal_userauthcookie + .as_ref() + .is_some_and(|val| val.len() > 5); + + username_valid && (prelogin_cookie_valid || portal_userauthcookie_valid) + } +} + +enum AuthResult { + NotFound, + Invalid, + Success(SamlAuthData), +} + +impl AuthResult { + fn new( + username: Option, + prelogin_cookie: Option, + portal_userauthcookie: Option, + ) -> Self { + if SamlAuthData::check(&username, &prelogin_cookie, &portal_userauthcookie) { + AuthResult::Success(SamlAuthData { + username: username.unwrap(), + prelogin_cookie, + portal_userauthcookie, + }) + } else { + AuthResult::Invalid + } + } +} + +pub(crate) struct AuthWindow { + portal: Portal, + event_loop: EventLoop, + webview: WebView, +} + +impl AuthWindow { + pub fn new(portal: Portal, user_agent: &str) -> anyhow::Result { + let event_loop = EventLoop::with_user_event(); + let window = WindowBuilder::new() + .with_title("GlobalProtect Login") + .build(&event_loop)?; + let mut web_context = WebContext::new(data_dir()); + let webview = WebViewBuilder::new(window)? + .with_user_agent(user_agent) + .with_web_context(&mut web_context) + .build()?; + + Ok(Self { + portal, + event_loop, + webview, + }) + } + + pub async fn run(&mut self, saml_request: Option<&str>) -> anyhow::Result { + let saml_request = match saml_request { + Some(saml_request) => Cow::Borrowed(saml_request), + None => Cow::Owned(Self::saml_request(&self.portal).await?), + }; + + self.setup_webview(&saml_request); + + let (sender, receiver) = mpsc::unbounded_channel::(); + self.setup_channel(receiver); + + let auth_data_ret: Rc>> = Default::default(); + let wv = self.webview.webview(); + self.event_loop.run_return(|event, _, control_flow| { + *control_flow = ControlFlow::Wait; + + match event { + Event::NewEvents(StartCause::Init) => info!("Auth window is ready"), + Event::WindowEvent { + event: WindowEvent::CloseRequested, + .. + } => { + info!("Close requested, exiting the auth window..."); + Self::send_auth_event(&sender, AuthEvent::Exit); + *control_flow = ControlFlow::Exit; + } + Event::UserEvent(UserEvent::AuthRequest(saml_request)) => { + Self::load_saml_request(wv.clone(), &saml_request); + } + Event::UserEvent(UserEvent::AuthFailed) => { + info!("Auth failed, retrying..."); + wv.run_javascript(r#" + var loading = document.createElement("div"); + loading.innerHTML = '
Got invalid token, retrying...
'; + loading.style = "position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(255, 255, 255, 0.85); z-index: 99999;"; + document.body.appendChild(loading); + "#, + Cancellable::NONE, + |_| info!("Injected loading element successfully"), + ); + Self::send_auth_event(&sender, AuthEvent::FetchAuthRequest); + } + Event::UserEvent(UserEvent::AuthSuccess(auth_data)) => { + info!("Auth success, exit the auth window..."); + *auth_data_ret.borrow_mut() = Some(auth_data); + Self::send_auth_event(&sender, AuthEvent::Exit); + *control_flow = ControlFlow::Exit; + } + Event::UserEvent(UserEvent::Exit) => { + info!("Exit event received, exiting the auth window..."); + Self::send_auth_event(&sender, AuthEvent::Exit); + *control_flow = ControlFlow::Exit; + } + _ => (), + } + }); + + auth_data_ret + .take() + .ok_or_else(|| anyhow::anyhow!("Auth window exited without auth data")) + } + + fn send_user_event(event_proxy: &EventLoopProxy, event: UserEvent) { + if let Err(err) = event_proxy.send_event(event) { + warn!("Failed to send user event: {}", err); + } + } + + fn send_auth_event(sender: &mpsc::UnboundedSender, event: AuthEvent) { + if let Err(err) = sender.send(event) { + warn!("Failed to send auth event: {}", err); + } + } + + async fn saml_request(portal: &Portal) -> anyhow::Result { + let prelogin = portal.prelogin().await?; + + if let Prelogin::Saml(SamlPrelogin { + method, request, .. + }) = prelogin + { + info!("Received SAML prelogin response, method: {}", method); + Ok(request) + } else { + Err(anyhow::anyhow!("Received non-SAML prelogin response")) + } + } + + fn load_saml_request(wv: Rc, saml_request: &str) { + if saml_request.starts_with("http") { + info!("Load the SAML request as URI..."); + wv.load_uri(saml_request); + } else { + info!("Load the SAML request as HTML..."); + wv.load_html(saml_request, None); + } + } + + fn setup_webview(&self, saml_request: &str) { + let wv = self.webview.webview(); + let event_proxy = self.event_loop.create_proxy(); + + Self::load_saml_request(wv.clone(), saml_request); + + wv.connect_load_changed(move |wv, event| { + if event != LoadEvent::Finished { + return; + } + + let uri = wv.uri().unwrap_or("".into()); + if uri.is_empty() { + warn!("Loaded an empty URI, auth failed"); + Self::send_user_event(&event_proxy, UserEvent::AuthFailed); + return; + } + + if uri == "about:blank" { + info!("Loaded about:blank, skipping"); + return; + } + + info!("Loaded URI: {}", uri); + if let Some(main_resource) = wv.main_resource() { + Self::read_auth_data(&main_resource, event_proxy.clone()); + } else { + warn!("No main resource found for {}, skipping", uri); + } + }); + + let event_proxy = self.event_loop.create_proxy(); + wv.connect_load_failed(move |_wv, _event, uri, err| { + warn!("Load failed: {:?}, {:?}", uri, err); + Self::send_user_event(&event_proxy, UserEvent::AuthFailed); + false + }); + } + + fn setup_channel(&self, mut receiver: mpsc::UnboundedReceiver) { + let portal = self.portal.clone(); + let event_proxy = self.event_loop.create_proxy(); + + tokio::spawn(async move { + loop { + match receiver.recv().await { + Some(AuthEvent::FetchAuthRequest) => { + info!("Fetching auth request..."); + match Self::saml_request(&portal).await { + Ok(request) => { + Self::send_user_event(&event_proxy, UserEvent::AuthRequest(request)) + } + Err(err) => { + warn!("Failed to fetch prelogin response: {}", err); + Self::send_user_event(&event_proxy, UserEvent::Exit); + } + } + } + Some(AuthEvent::Exit) => { + info!("Auth window exited, exiting the receiver..."); + break; + } + None => { + info!("Auth event channel closed, exiting the receiver..."); + break; + } + } + } + }); + } + + fn read_auth_data(main_resource: &WebResource, event_proxy: EventLoopProxy) { + if main_resource.response().is_none() { + info!("No response found for main resource"); + return; + } + + let response = main_resource.response().unwrap(); + info!("Trying to read auth data from response headers..."); + match read_auth_data_from_headers(&response) { + AuthResult::Success(auth_data) => { + Self::send_user_event(&event_proxy, UserEvent::AuthSuccess(auth_data)); + } + AuthResult::NotFound => { + info!("No auth data found in response headers, trying to read from HTML..."); + read_auth_data_from_body(main_resource, move |auth_result| match auth_result { + AuthResult::Success(auth_data) => { + Self::send_user_event(&event_proxy, UserEvent::AuthSuccess(auth_data)); + } + AuthResult::Invalid => { + Self::send_user_event(&event_proxy, UserEvent::AuthFailed); + } + AuthResult::NotFound => { + info!("No auth data found in HTML, it may not be the '/SAML20/SP/ACS' endpoint"); + } + }); + } + AuthResult::Invalid => { + info!("Found invalid auth data in response headers, trying to read from HTML..."); + read_auth_data_from_body(main_resource, move |auth_result| { + if let AuthResult::Success(auth_data) = auth_result { + Self::send_user_event(&event_proxy, UserEvent::AuthSuccess(auth_data)); + } else { + Self::send_user_event(&event_proxy, UserEvent::AuthFailed); + } + }); + } + } + } +} + +fn data_dir() -> Option { + ProjectDirs::from("com.yuezk", "GlobalProtect-openconnect", "gpauth") + .map(|dirs| dirs.data_dir().into()) +} + +fn read_auth_data_from_headers(response: &URIResponse) -> AuthResult { + response + .http_headers() + .map_or(AuthResult::NotFound, |mut headers| { + match headers.get("saml-auth-status") { + Some(saml_status) if saml_status == "1" => { + info!("Found valid SAML status in header"); + + let username = headers.get("saml-username").map(GString::into); + let prelogin_cookie = headers.get("prelogin-cookie").map(GString::into); + let portal_userauthcookie = + headers.get("portal-userauthcookie").map(GString::into); + + AuthResult::new(username, prelogin_cookie, portal_userauthcookie) + } + Some(saml_status) => { + info!("Found invalid SAML status in header: {}", saml_status); + AuthResult::Invalid + } + None => { + info!("No auth data found in response headers"); + AuthResult::NotFound + } + } + }) +} + +fn read_auth_data_from_body(main_resource: &WebResource, callback: F) +where + F: FnOnce(AuthResult) + Send + 'static, +{ + main_resource.data(Cancellable::NONE, |data| { + let auth_result = data.map_or(AuthResult::NotFound, |data| { + let html = String::from_utf8_lossy(&data).to_string(); + read_auth_data_from_html(&html) + }); + + callback(auth_result); + }); +} + +fn read_auth_data_from_html(html: &str) -> AuthResult { + if html.contains("Temporarily Unavailable") { + info!("Found 'Temporarily Unavailable' in HTML, auth failed"); + return AuthResult::Invalid; + } + + match parse_xml_tag(html, "saml-auth-status") { + Some(saml_status) if saml_status == "1" => { + info!("Found valid status in HTML"); + + let username = parse_xml_tag(html, "saml-username"); + let prelogin_cookie = parse_xml_tag(html, "prelogin-cookie"); + let portal_userauthcookie = parse_xml_tag(html, "portal-userauthcookie"); + + AuthResult::new(username, prelogin_cookie, portal_userauthcookie) + } + Some(saml_status) => { + info!("Found invalid SAML status in HTML: {}", saml_status); + AuthResult::Invalid + } + None => { + info!("No auth data found in HTML"); + AuthResult::NotFound + } + } +} + +fn parse_xml_tag(html: &str, tag: &str) -> Option { + let re = Regex::new(&format!("<{}>(.*)", tag, tag)).unwrap(); + re.captures(html) + .and_then(|captures| captures.get(1)) + .map(|m| m.as_str().to_string()) +} diff --git a/gpauth/src/lib.rs b/gpauth/src/lib.rs new file mode 100644 index 0000000..1659ba5 --- /dev/null +++ b/gpauth/src/lib.rs @@ -0,0 +1,6 @@ +mod auth_window; +mod saml_auth; +mod standard_auth; + +pub use saml_auth::saml_auth; +pub use standard_auth::standard_auth; diff --git a/gpauth/src/main.rs b/gpauth/src/main.rs new file mode 100644 index 0000000..9fcfd9a --- /dev/null +++ b/gpauth/src/main.rs @@ -0,0 +1,48 @@ +use clap::{arg, Command}; +use gpauth::saml_auth; +use gpcommon::portal::{Portal, Prelogin, SamlPrelogin}; +use serde_json::json; + +fn cli() -> Command { + Command::new("gpauth") + .about("GlobalProtect-openconnect authentication helper") + .arg_required_else_help(true) + .arg(arg!( "The GlobalProtect server")) +} + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + fern::Dispatch::new() + .format(|out, message, record| { + out.finish(format_args!( + "[{} {} {}] {}", + humantime::format_rfc3339_millis(std::time::SystemTime::now()), + record.level(), + record.target(), + message + )) + }) + .level(log::LevelFilter::Info) + .chain(std::io::stderr()) + .apply()?; + + let matches = cli().get_matches(); + let server = matches.get_one::("SERVER").expect("Missing server"); + let address = if server.starts_with("https://") || server.starts_with("http://") { + server.to_string() + } else { + format!("https://{}", server) + }; + + let portal = Portal::new(&address); + let saml_request = match portal.prelogin().await? { + Prelogin::Saml(SamlPrelogin { request, .. }) => request, + _ => anyhow::bail!("Prelogin response is not SAML"), + }; + let auth_data = saml_auth(&portal, Some(&saml_request)).await?; + + // Output the auth data as JSON, so that the client can parse it + println!("{}", json!(auth_data)); + + Ok(()) +} diff --git a/gpauth/src/saml_auth.rs b/gpauth/src/saml_auth.rs new file mode 100644 index 0000000..b1292c6 --- /dev/null +++ b/gpauth/src/saml_auth.rs @@ -0,0 +1,9 @@ +use crate::auth_window::{SamlAuthData, AuthWindow}; +use gpcommon::portal::Portal; + +pub async fn saml_auth(portal: &Portal, saml_request: Option<&str>) -> anyhow::Result { + let user_agent = "PAN GlobalProtect"; + let mut auth_window = AuthWindow::new(portal.clone(), user_agent)?; + + auth_window.run(saml_request).await +} diff --git a/gpauth/src/standard_auth.rs b/gpauth/src/standard_auth.rs new file mode 100644 index 0000000..228a598 --- /dev/null +++ b/gpauth/src/standard_auth.rs @@ -0,0 +1,3 @@ +pub fn standard_auth(server: &str) { + +} diff --git a/gpclient/Cargo.toml b/gpclient/Cargo.toml index 480561a..7e40d86 100644 --- a/gpclient/Cargo.toml +++ b/gpclient/Cargo.toml @@ -7,8 +7,10 @@ edition = "2021" [dependencies] gpcommon = { path = "../gpcommon" } +gpauth = { path = "../gpauth" } anyhow.workspace = true clap.workspace = true reqwest.workspace = true tokio.workspace = true url.workspace = true +libc = "0.2" diff --git a/gpclient/src/main.rs b/gpclient/src/main.rs index 94815ae..294f825 100644 --- a/gpclient/src/main.rs +++ b/gpclient/src/main.rs @@ -1,11 +1,8 @@ use clap::{arg, Command, Parser}; use gpcommon::{Client, SOCKET_PATH}; -use portal::Portal; use tokio::{io::AsyncReadExt, net::UnixStream, sync::mpsc}; use url::Url; -mod portal; - fn cli() -> Command { Command::new("gpclient") .about("GlobalProtect-openconnect CLI client") @@ -55,7 +52,8 @@ async fn main() { let url = Url::parse(&server).expect("Invalid server URL"); let host = url.host_str().expect("Invalid server URL"); - let portal = Portal::new(host); + // let portal = Portal::new(host); + // let prelogin = portal.prelogin().await; } Some(("disconnect", _)) => { println!("Disconnecting..."); diff --git a/gpclient/src/portal.rs b/gpclient/src/portal.rs index 92605ff..d616c7b 100644 --- a/gpclient/src/portal.rs +++ b/gpclient/src/portal.rs @@ -4,11 +4,14 @@ pub(crate) struct Portal<'a> { address: &'a str, } -struct SamlPrelogin {} +#[derive(Debug)] +pub(crate) struct SamlPrelogin {} -struct StandardPrelogin {} +#[derive(Debug)] +pub(crate) struct StandardPrelogin {} -enum Prelogin { +#[derive(Debug)] +pub(crate) enum Prelogin { Saml(SamlPrelogin), Standard(StandardPrelogin), } @@ -19,9 +22,10 @@ impl<'a> Portal<'a> { } // Preform the Portal prelogin - async fn prelogin(&self) -> Result { + pub async fn prelogin(&self) -> Result { let prelogin_url = format!("https://{}/global-protect/prelogin.esp", self.address); - let res_xml = reqwest::get(prelogin_url).await?.text().await?; + let res_xml = reqwest::get(&prelogin_url).await?.text().await?; + println!("{}", res_xml); Ok(Prelogin::Standard(StandardPrelogin {})) @@ -38,9 +42,11 @@ mod tests { assert_eq!(portal.address, "vpn.example.com"); } - #[test] - fn test_prelogin() { - let portal = Portal::new("vpn.example.com"); - let prelogin = portal.prelogin(); + #[tokio::test] + async fn test_prelogin() { + let portal = Portal::new("vpn.microstrategy.com"); + let prelogin = portal.prelogin().await; + + assert!(prelogin.is_ok()); } } diff --git a/gpcommon/Cargo.toml b/gpcommon/Cargo.toml index 9580e0f..c11769f 100644 --- a/gpcommon/Cargo.toml +++ b/gpcommon/Cargo.toml @@ -3,18 +3,19 @@ name = "gpcommon" version.workspace = true edition = "2021" -# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html - [dependencies] anyhow.workspace = true async-trait.workspace = true +base64.workspace = true bytes.workspace = true configparser.workspace = true data-encoding.workspace = true is_executable.workspace = true lexopt.workspace = true log.workspace = true +reqwest.workspace = true ring.workspace = true +roxmltree.workspace = true serde_json.workspace = true serde.workspace = true shlex.workspace = true @@ -23,5 +24,8 @@ thiserror.workspace = true tokio-util.workspace = true tokio.workspace = true +[dev-dependencies] +mockito.workspace = true + [build-dependencies] cc = "1.0" diff --git a/gpcommon/src/lib.rs b/gpcommon/src/lib.rs index b3ae551..de975d7 100644 --- a/gpcommon/src/lib.rs +++ b/gpcommon/src/lib.rs @@ -17,6 +17,7 @@ mod response; pub mod server; mod vpn; mod writer; +pub mod portal; pub(crate) use request::Request; pub(crate) use request::RequestPool; diff --git a/gpcommon/src/portal.rs b/gpcommon/src/portal.rs new file mode 100644 index 0000000..3be0738 --- /dev/null +++ b/gpcommon/src/portal.rs @@ -0,0 +1,201 @@ +use anyhow::bail; +use base64::{engine::general_purpose, Engine}; +use roxmltree::Document; + +#[derive(Debug, Clone)] +pub struct Portal { + address: String, +} + +pub enum PortalCredential { + Standard(String, String), + Prelogin(String), + Cached(String, String), +} + +#[derive(Debug)] +pub struct SamlPrelogin { + pub region: String, + pub method: String, + pub request: String, +} + +#[derive(Debug)] +pub struct StandardPrelogin { + pub region: String, + pub label_username: String, + pub label_password: String, + pub auth_message: String, +} + +#[derive(Debug)] +pub enum Prelogin { + Saml(SamlPrelogin), + Standard(StandardPrelogin), +} + +impl Portal { + pub fn new(address: &str) -> Self { + Self { + address: address.to_string(), + } + } + + pub async fn prelogin(&self) -> anyhow::Result { + let prelogin_url = format!("{}/global-protect/prelogin.esp", self.address); + let client = reqwest::Client::builder() + .user_agent("PAN GlobalProtect") + .build()?; + let res_xml = client.get(&prelogin_url).send().await?.text().await?; + let doc = Document::parse(&res_xml)?; + + let status = get_child_text(&doc, "status") + .ok_or_else(|| anyhow::anyhow!("Prelogin response does not contain status element"))?; + // Check the status of the prelogin response + if status.to_uppercase() != "SUCCESS" { + let msg = get_child_text(&doc, "msg").unwrap_or(String::from("Unknown error")); + bail!("Prelogin failed: {}", msg) + } + + let region = get_child_text(&doc, "region") + .ok_or_else(|| anyhow::anyhow!("Prelogin response does not contain region element"))?; + + let saml_method = get_child_text(&doc, "saml-auth-method"); + let saml_request = get_child_text(&doc, "saml-request"); + // Check if the prelogin response is SAML + if saml_method.is_some() && saml_request.is_some() { + return Ok(Prelogin::Saml(SamlPrelogin { + region, + method: saml_method.unwrap(), + request: base64_decode(&saml_request.unwrap())?, + })); + } + + let label_username = get_child_text(&doc, "username-label"); + let label_password = get_child_text(&doc, "password-label"); + // Check if the prelogin response is standard login + if label_username.is_some() && label_password.is_some() { + let auth_message = get_child_text(&doc, "authentication-message") + .unwrap_or(String::from("Please enter the login credentials")); + return Ok(Prelogin::Standard(StandardPrelogin { + region, + auth_message, + label_username: label_username.unwrap(), + label_password: label_password.unwrap(), + })); + } + + bail!("Unknown prelogin response"); + } + + pub fn retrieve_config(&self, credential: &PortalCredential) { + todo!() + } +} + +fn get_child_text(doc: &Document, name: &str) -> Option { + let node = doc.descendants().find(|n| n.has_tag_name(name))?; + node.text().map(|s| s.to_string()) +} + +fn base64_decode(s: &str) -> anyhow::Result { + let engine = general_purpose::STANDARD; + let decoded = engine.decode(s)?; + Ok(String::from_utf8(decoded)?) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_new() { + let portal = Portal::new("vpn.example.com"); + assert_eq!(portal.address, "vpn.example.com"); + } + + #[tokio::test] + async fn test_prelogin_saml() { + let mut s = mockito::Server::new(); + let mock = s + .mock("GET", "/global-protect/prelogin.esp") + .with_body( + r#" + +Success + +false + + +Enter login credentials +Username +Password +1 +yes + + +0 +REDIRECT +600 +0 +U0FNTFJlcXVlc3Q9eHh4 +noCN +"#, + ) + .create(); + + let url = s.url(); + let portal = Portal::new(&url); + let prelogin = portal.prelogin().await.unwrap(); + let saml = match prelogin { + Prelogin::Saml(saml) => saml, + _ => panic!("Prelogin is not SAML"), + }; + + mock.assert(); + assert!(saml.method == "REDIRECT"); + assert!(saml.request.contains("SAMLRequest")); + assert!(saml.region == "CN") + } + + #[tokio::test] + async fn test_prelogin_standard() { + let mut s = mockito::Server::new(); + let mock = s + .mock("GET", "/global-protect/prelogin.esp") + .with_body( + r#" + +Success + +false + + +Enter login credentials +Username +Password +1 +yesnoUS +"#, + ) + .create(); + + let url = s.url(); + let portal = Portal::new(&url); + let prelogin = portal.prelogin().await.unwrap(); + + let standard = match prelogin { + Prelogin::Standard(standard) => standard, + _ => panic!("Prelogin is not standard"), + }; + + mock.assert(); + assert!(standard.label_username == "Username"); + assert!(standard.label_password == "Password"); + assert!(standard.auth_message == "Enter login credentials"); + assert!(standard.region == "US"); + } + + #[tokio::test] + async fn test_retrieve_config_standard_credential() {} +} diff --git a/gpgui/src-tauri/Cargo.toml b/gpgui/src-tauri/Cargo.toml index 5a56ce9..f98bf6d 100644 --- a/gpgui/src-tauri/Cargo.toml +++ b/gpgui/src-tauri/Cargo.toml @@ -12,11 +12,12 @@ rust-version = "1.59" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [build-dependencies] -tauri-build = { version = "1.3", features = [] } +tauri-build = { version = "1.4.0", features = [] } [dependencies] gpcommon = { path = "../../gpcommon" } -tauri = { version = "1.3", features = ["fs-write-file", "http-all", "os-all", "process-exit", "shell-open", "window-all", "window-data-url"] } +gpauth = { path = "../../gpauth" } +tauri = { version = "1.4.0", features = ["fs-write-file", "http-all", "os-all", "process-exit", "shell-open", "window-all", "window-data-url"] } tauri-plugin-log = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v1", features = [ "colored", ] } diff --git a/gpgui/src-tauri/src/auth.rs b/gpgui/src-tauri/src/auth.rs index 0ebe9d7..c7f2764 100644 --- a/gpgui/src-tauri/src/auth.rs +++ b/gpgui/src-tauri/src/auth.rs @@ -147,7 +147,7 @@ fn build_window(app_handle: &AppHandle, ua: &str) -> tauri::Result { .title("GlobalProtect Login") .inner_size(600.0, 500.0) .min_inner_size(370.0, 600.0) - .user_agent(ua) + .user_agent("PAN GlobalProtect") .always_on_top(true) .focused(true) .center() diff --git a/gpgui/src-tauri/src/error.rs b/gpgui/src-tauri/src/error.rs new file mode 100644 index 0000000..d869173 --- /dev/null +++ b/gpgui/src-tauri/src/error.rs @@ -0,0 +1,10 @@ +#[derive(thiserror::Error, Debug)] +#[non_exhaustive] +pub(crate) enum Error { + #[error("Failed to encrypt data. {0}")] + Encrypt(String), + #[error("Failed to decrypt data. {0}")] + Decrypt(String), + #[error(transparent)] + Other(#[from] anyhow::Error), +} diff --git a/gpservice/Cargo.toml b/gpservice/Cargo.toml index 8d49a36..3dc7949 100644 --- a/gpservice/Cargo.toml +++ b/gpservice/Cargo.toml @@ -5,10 +5,10 @@ edition = "2021" [dependencies] gpcommon = { path = "../gpcommon" } -tokio.workspace = true +fern.workspace = true +humantime.workspace = true log.workspace = true -fern = "0.6" -humantime = "2.1" +tokio.workspace = true [build-dependencies] gpcommon = { path = "../gpcommon" }