From c3bd7aeb936f8da5fb04dbd47b65f21c14c08f67 Mon Sep 17 00:00:00 2001 From: Kevin Yue Date: Mon, 22 Jan 2024 09:43:15 -0500 Subject: [PATCH] Support SSO using default browser --- .vscode/settings.json | 5 +- Cargo.lock | 47 +++++++++++++++++-- Cargo.toml | 2 +- README.md | 1 + apps/gpclient/src/launch_gui.rs | 26 +++++++++- apps/gpservice/com.yuezk.gpservice.policy | 19 -------- apps/gpservice/src/handlers.rs | 7 +++ apps/gpservice/src/routes.rs | 1 + crates/gpapi/Cargo.toml | 2 + crates/gpapi/src/auth.rs | 35 ++++++++++++++ crates/gpapi/src/credential.rs | 13 ++++- crates/gpapi/src/gp_params.rs | 20 ++++++-- crates/gpapi/src/portal/prelogin.rs | 21 +++++++-- .../src/process/browser_authenticator.rs | 34 ++++++++++++++ crates/gpapi/src/process/mod.rs | 2 + crates/gpapi/src/service/event.rs | 2 + 16 files changed, 201 insertions(+), 36 deletions(-) delete mode 100644 apps/gpservice/com.yuezk.gpservice.policy create mode 100644 crates/gpapi/src/process/browser_authenticator.rs diff --git a/.vscode/settings.json b/.vscode/settings.json index 7a00839a..b4ce30f3 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -11,8 +11,10 @@ "dotenvy", "getconfig", "globalprotect", + "globalprotectcallback", "gpapi", "gpauth", + "gpcallback", "gpclient", "gpcommon", "gpgui", @@ -50,5 +52,6 @@ "wmctrl", "XAUTHORITY", "yuezk" - ] + ], + "rust-analyzer.cargo.features": "all", } diff --git a/Cargo.lock b/Cargo.lock index 8c737ea8..bea24fc2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1423,7 +1423,7 @@ dependencies = [ [[package]] name = "gpapi" -version = "2.0.0-beta4" +version = "2.0.0-beta5" dependencies = [ "anyhow", "base64 0.21.5", @@ -1431,6 +1431,7 @@ dependencies = [ "clap", "dotenvy_macro", "log", + "open", "redact-engine", "regex", "reqwest", @@ -1451,7 +1452,7 @@ dependencies = [ [[package]] name = "gpauth" -version = "2.0.0-beta4" +version = "2.0.0-beta5" dependencies = [ "anyhow", "clap", @@ -1471,7 +1472,7 @@ dependencies = [ [[package]] name = "gpclient" -version = "2.0.0-beta4" +version = "2.0.0-beta5" dependencies = [ "anyhow", "clap", @@ -1492,7 +1493,7 @@ dependencies = [ [[package]] name = "gpservice" -version = "2.0.0-beta4" +version = "2.0.0-beta5" dependencies = [ "anyhow", "axum", @@ -1963,6 +1964,15 @@ version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f518f335dce6725a761382244631d86cf0ccb2863413590b31338feb467f9c3" +[[package]] +name = "is-docker" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" +dependencies = [ + "once_cell", +] + [[package]] name = "is-terminal" version = "0.4.10" @@ -1974,6 +1984,16 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "is-wsl" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" +dependencies = [ + "is-docker", + "once_cell", +] + [[package]] name = "is_executable" version = "1.0.1" @@ -2445,9 +2465,20 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "624a8340c38c1b80fd549087862da4ba43e08858af025b236e509b6649fc13d5" +[[package]] +name = "open" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90878fb664448b54c4e592455ad02831e23a3f7e157374a8b95654731aac7349" +dependencies = [ + "is-wsl", + "libc", + "pathdiff", +] + [[package]] name = "openconnect" -version = "2.0.0-beta4" +version = "2.0.0-beta5" dependencies = [ "cc", "is_executable", @@ -2574,6 +2605,12 @@ version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "de3145af08024dea9fa9914f381a17b8fc6034dfb00f3a84013f7ff43f29ed4c" +[[package]] +name = "pathdiff" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8835116a5c179084a830efb3adc117ab007512b535bc1a21c991d3b32a6b44dd" + [[package]] name = "percent-encoding" version = "2.3.1" diff --git a/Cargo.toml b/Cargo.toml index 62184f37..50db8499 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ resolver = "2" members = ["crates/*", "apps/gpclient", "apps/gpservice", "apps/gpauth"] [workspace.package] -version = "2.0.0-beta4" +version = "2.0.0-beta5" authors = ["Kevin Yue "] homepage = "https://github.com/yuezk/GlobalProtect-openconnect" edition = "2021" diff --git a/README.md b/README.md index d3a6b661..31e8fe23 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ A GUI for GlobalProtect VPN, based on OpenConnect, supports the SSO authenticati - [x] Better Linux support - [x] Support both CLI and GUI - [x] Support both SSO and non-SSO authentication +- [x] Support authentication using default browser - [x] Support multiple portals - [x] Support gateway selection - [x] Support auto-connect on startup diff --git a/apps/gpclient/src/launch_gui.rs b/apps/gpclient/src/launch_gui.rs index e8f84682..6a215699 100644 --- a/apps/gpclient/src/launch_gui.rs +++ b/apps/gpclient/src/launch_gui.rs @@ -10,7 +10,12 @@ use log::info; #[derive(Args)] pub(crate) struct LaunchGuiArgs { - #[clap(long, help = "Launch the GUI minimized")] + #[arg( + required = false, + help = "The authentication data, used for the default browser authentication" + )] + auth_data: Option, + #[arg(long, help = "Launch the GUI minimized")] minimized: bool, } @@ -30,6 +35,12 @@ impl<'a> LaunchGuiHandler<'a> { anyhow::bail!("`launch-gui` cannot be run as root"); } + let auth_data = self.args.auth_data.as_deref().unwrap_or_default(); + if !auth_data.is_empty() { + // Process the authentication data, its format is `globalprotectcallback:` + return feed_auth_data(auth_data).await; + } + if try_active_gui().await.is_ok() { info!("The GUI is already running"); return Ok(()); @@ -66,6 +77,19 @@ impl<'a> LaunchGuiHandler<'a> { } } +async fn feed_auth_data(auth_data: &str) -> anyhow::Result<()> { + let service_endpoint = http_endpoint().await?; + + reqwest::Client::default() + .post(format!("{}/auth-data", service_endpoint)) + .json(&auth_data) + .send() + .await? + .error_for_status()?; + + Ok(()) +} + async fn try_active_gui() -> anyhow::Result<()> { let service_endpoint = http_endpoint().await?; diff --git a/apps/gpservice/com.yuezk.gpservice.policy b/apps/gpservice/com.yuezk.gpservice.policy deleted file mode 100644 index 3c2128f6..00000000 --- a/apps/gpservice/com.yuezk.gpservice.policy +++ /dev/null @@ -1,19 +0,0 @@ - - - - GlobalProtect-openconnect - https://github.com/yuezk/GlobalProtect-openconnect - gpgui - - Run GPService as root - Authentication is required to run the GPService as root - - yes - yes - yes - - /home/kevin/Documents/repos/gp/target/debug/gpservice - --with-gui - true - - diff --git a/apps/gpservice/src/handlers.rs b/apps/gpservice/src/handlers.rs index 4f90b065..3e2c6c96 100644 --- a/apps/gpservice/src/handlers.rs +++ b/apps/gpservice/src/handlers.rs @@ -21,6 +21,13 @@ pub(crate) async fn active_gui(State(ctx): State>) -> impl ctx.send_event(WsEvent::ActiveGui).await; } +pub(crate) async fn auth_data( + State(ctx): State>, + body: String, +) -> impl IntoResponse { + ctx.send_event(WsEvent::AuthData(body)).await; +} + pub(crate) async fn ws_handler( ws: WebSocketUpgrade, State(ctx): State>, diff --git a/apps/gpservice/src/routes.rs b/apps/gpservice/src/routes.rs index 780b11f6..5ea5f657 100644 --- a/apps/gpservice/src/routes.rs +++ b/apps/gpservice/src/routes.rs @@ -8,6 +8,7 @@ pub(crate) fn routes(ctx: Arc) -> Router { Router::new() .route("/health", get(handlers::health)) .route("/active-gui", post(handlers::active_gui)) + .route("/auth-data", post(handlers::auth_data)) .route("/ws", get(handlers::ws_handler)) .with_state(ctx) } diff --git a/crates/gpapi/Cargo.toml b/crates/gpapi/Cargo.toml index 7e8ab927..ee8fa37c 100644 --- a/crates/gpapi/Cargo.toml +++ b/crates/gpapi/Cargo.toml @@ -28,7 +28,9 @@ uzers.workspace = true tauri = { workspace = true, optional = true } clap = { workspace = true, optional = true } +open = { version = "5", optional = true } [features] tauri = ["dep:tauri"] clap = ["dep:clap"] +browser-auth = ["dep:open"] diff --git a/crates/gpapi/src/auth.rs b/crates/gpapi/src/auth.rs index 0e446662..a3dd665c 100644 --- a/crates/gpapi/src/auth.rs +++ b/crates/gpapi/src/auth.rs @@ -1,3 +1,5 @@ +use anyhow::bail; +use regex::Regex; use serde::{Deserialize, Serialize}; #[derive(Debug, Serialize, Deserialize)] @@ -37,6 +39,32 @@ impl SamlAuthData { } } + pub fn parse_html(html: &str) -> anyhow::Result { + match parse_xml_tag(html, "saml-auth-status") { + Some(saml_status) if saml_status == "1" => { + 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"); + + if SamlAuthData::check(&username, &prelogin_cookie, &portal_userauthcookie) { + return Ok(SamlAuthData::new( + username.unwrap(), + prelogin_cookie, + portal_userauthcookie, + )); + } + + bail!("Found invalid auth data in HTML"); + } + Some(status) => { + bail!("Found invalid SAML status {} in HTML", status); + } + None => { + bail!("No auth data found in HTML"); + } + } + } + pub fn username(&self) -> &str { &self.username } @@ -61,3 +89,10 @@ impl SamlAuthData { username_valid && (prelogin_cookie_valid || portal_userauthcookie_valid) } } + +pub 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/crates/gpapi/src/credential.rs b/crates/gpapi/src/credential.rs index a99d3eec..7890283f 100644 --- a/crates/gpapi/src/credential.rs +++ b/crates/gpapi/src/credential.rs @@ -3,7 +3,7 @@ use std::collections::HashMap; use serde::{Deserialize, Serialize}; use specta::Type; -use crate::auth::SamlAuthData; +use crate::{auth::SamlAuthData, utils::base64::decode_to_string}; #[derive(Debug, Serialize, Deserialize, Type, Clone)] #[serde(rename_all = "camelCase")] @@ -151,6 +151,17 @@ pub enum Credential { } impl Credential { + /// Create a credential from a globalprotectcallback: + pub fn parse_gpcallback(auth_data: &str) -> anyhow::Result { + // Remove the surrounding quotes + let auth_data = auth_data.trim_matches('"'); + let auth_data = auth_data.trim_start_matches("globalprotectcallback:"); + let auth_data = decode_to_string(auth_data)?; + let auth_data = SamlAuthData::parse_html(&auth_data)?; + + Self::try_from(auth_data) + } + pub fn username(&self) -> &str { match self { Credential::Password(cred) => cred.username(), diff --git a/crates/gpapi/src/gp_params.rs b/crates/gpapi/src/gp_params.rs index 8ac98c6c..ab48e579 100644 --- a/crates/gpapi/src/gp_params.rs +++ b/crates/gpapi/src/gp_params.rs @@ -50,6 +50,7 @@ pub struct GpParams { client_version: Option, computer: String, ignore_tls_errors: bool, + prefer_default_browser: bool, } impl GpParams { @@ -69,6 +70,10 @@ impl GpParams { self.ignore_tls_errors } + pub fn prefer_default_browser(&self) -> bool { + self.prefer_default_browser + } + pub(crate) fn to_params(&self) -> HashMap<&str, &str> { let mut params: HashMap<&str, &str> = HashMap::new(); let client_os = self.client_os.as_str(); @@ -88,9 +93,10 @@ impl GpParams { params.insert("os-version", os_version); } - if let Some(client_version) = &self.client_version { - params.insert("clientgpversion", client_version); - } + // NOTE: Do not include clientgpversion for now + // if let Some(client_version) = &self.client_version { + // params.insert("clientgpversion", client_version); + // } params } @@ -103,6 +109,7 @@ pub struct GpParamsBuilder { client_version: Option, computer: String, ignore_tls_errors: bool, + prefer_default_browser: bool, } impl GpParamsBuilder { @@ -114,6 +121,7 @@ impl GpParamsBuilder { client_version: Default::default(), computer: whoami::hostname(), ignore_tls_errors: false, + prefer_default_browser: false, } } @@ -147,6 +155,11 @@ impl GpParamsBuilder { self } + pub fn prefer_default_browser(&mut self, prefer_default_browser: bool) -> &mut Self { + self.prefer_default_browser = prefer_default_browser; + self + } + pub fn build(&self) -> GpParams { GpParams { user_agent: self.user_agent.clone(), @@ -155,6 +168,7 @@ impl GpParamsBuilder { client_version: self.client_version.clone(), computer: self.computer.clone(), ignore_tls_errors: self.ignore_tls_errors, + prefer_default_browser: self.prefer_default_browser, } } } diff --git a/crates/gpapi/src/portal/prelogin.rs b/crates/gpapi/src/portal/prelogin.rs index a34522d7..94f51840 100644 --- a/crates/gpapi/src/portal/prelogin.rs +++ b/crates/gpapi/src/portal/prelogin.rs @@ -26,6 +26,7 @@ const REQUIRED_PARAMS: [&str; 8] = [ pub struct SamlPrelogin { region: String, saml_request: String, + support_default_browser: bool, } impl SamlPrelogin { @@ -36,6 +37,10 @@ impl SamlPrelogin { pub fn saml_request(&self) -> &str { &self.saml_request } + + pub fn support_default_browser(&self) -> bool { + self.support_default_browser + } } #[derive(Debug, Serialize, Type, Clone)] @@ -86,14 +91,14 @@ pub async fn prelogin(portal: &str, gp_params: &GpParams) -> anyhow::Result anyhow::Result { + auth_request: &'a str, +} + +impl BrowserAuthenticator<'_> { + pub fn new(auth_request: &str) -> BrowserAuthenticator { + BrowserAuthenticator { auth_request } + } + + pub fn authenticate(&self) -> anyhow::Result<()> { + if self.auth_request.starts_with("http") { + open::that_detached(self.auth_request)?; + } else { + let html_file = temp_dir().join("gpauth.html"); + let mut file = std::fs::File::create(&html_file)?; + + file.write_all(self.auth_request.as_bytes())?; + + open::that_detached(html_file)?; + } + + Ok(()) + } +} + +impl Drop for BrowserAuthenticator<'_> { + fn drop(&mut self) { + // Cleanup the temporary file + let html_file = temp_dir().join("gpauth.html"); + let _ = std::fs::remove_file(html_file); + } +} diff --git a/crates/gpapi/src/process/mod.rs b/crates/gpapi/src/process/mod.rs index e82d429f..b0842e44 100644 --- a/crates/gpapi/src/process/mod.rs +++ b/crates/gpapi/src/process/mod.rs @@ -1,5 +1,7 @@ pub(crate) mod command_traits; pub mod auth_launcher; +#[cfg(feature = "browser-auth")] +pub mod browser_authenticator; pub mod gui_launcher; pub mod service_launcher; diff --git a/crates/gpapi/src/service/event.rs b/crates/gpapi/src/service/event.rs index 869b9809..f685d727 100644 --- a/crates/gpapi/src/service/event.rs +++ b/crates/gpapi/src/service/event.rs @@ -7,4 +7,6 @@ use super::vpn_state::VpnState; pub enum WsEvent { VpnState(VpnState), ActiveGui, + /// External authentication data + AuthData(String), }