refactor: Improve the saml auth

This commit is contained in:
Kevin Yue 2023-05-31 12:29:24 +08:00
parent d975f981cc
commit c07e232ec2
14 changed files with 316 additions and 157 deletions

View File

@ -4,6 +4,7 @@
"clientos", "clientos",
"gpcommon", "gpcommon",
"jnlp", "jnlp",
"oneshot",
"openconnect", "openconnect",
"prelogin", "prelogin",
"prelogon", "prelogon",

22
Cargo.lock generated
View File

@ -62,6 +62,7 @@ dependencies = [
"tauri-plugin-log", "tauri-plugin-log",
"tokio", "tokio",
"url", "url",
"veil",
"webkit2gtk", "webkit2gtk",
] ]
@ -3407,6 +3408,27 @@ version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
[[package]]
name = "veil"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb8e42ca783c4c7ced40f4f0e11f13d545791c002a2e7adbe6d740b853087880"
dependencies = [
"once_cell",
"veil-macros",
]
[[package]]
name = "veil-macros"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1eef6b882bba6052c6ab6a751f8f765794de7f957cbf0c5a97e7d2b46a3ae60d"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.16",
]
[[package]] [[package]]
name = "version-compare" name = "version-compare"
version = "0.0.11" version = "0.0.11"

View File

@ -21,7 +21,7 @@
"tauri-plugin-log-api": "github:tauri-apps/tauri-plugin-log" "tauri-plugin-log-api": "github:tauri-apps/tauri-plugin-log"
}, },
"devDependencies": { "devDependencies": {
"@tauri-apps/cli": "^1.2.3", "@tauri-apps/cli": "^1.3.1",
"@types/react": "^18.0.27", "@types/react": "^18.0.27",
"@types/react-dom": "^18.0.10", "@types/react-dom": "^18.0.10",
"@vitejs/plugin-react-swc": "^3.0.0", "@vitejs/plugin-react-swc": "^3.0.0",

62
gpgui/pnpm-lock.yaml generated
View File

@ -34,8 +34,8 @@ dependencies:
devDependencies: devDependencies:
'@tauri-apps/cli': '@tauri-apps/cli':
specifier: ^1.2.3 specifier: ^1.3.1
version: 1.2.3 version: 1.3.1
'@types/react': '@types/react':
specifier: ^18.0.27 specifier: ^18.0.27
version: 18.0.28 version: 18.0.28
@ -853,8 +853,8 @@ packages:
engines: {node: '>= 14.6.0', npm: '>= 6.6.0', yarn: '>= 1.19.1'} engines: {node: '>= 14.6.0', npm: '>= 6.6.0', yarn: '>= 1.19.1'}
dev: false dev: false
/@tauri-apps/cli-darwin-arm64@1.2.3: /@tauri-apps/cli-darwin-arm64@1.3.1:
resolution: {integrity: sha512-phJN3fN8FtZZwqXg08bcxfq1+X1JSDglLvRxOxB7VWPq+O5SuB8uLyssjJsu+PIhyZZnIhTGdjhzLSFhSXfLsw==} resolution: {integrity: sha512-QlepYVPgOgspcwA/u4kGG4ZUijlXfdRtno00zEy+LxinN/IRXtk+6ErVtsmoLi1ZC9WbuMwzAcsRvqsD+RtNAg==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [arm64] cpu: [arm64]
os: [darwin] os: [darwin]
@ -862,8 +862,8 @@ packages:
dev: true dev: true
optional: true optional: true
/@tauri-apps/cli-darwin-x64@1.2.3: /@tauri-apps/cli-darwin-x64@1.3.1:
resolution: {integrity: sha512-jFZ/y6z8z6v4yliIbXKBXA7BJgtZVMsITmEXSuD6s5+eCOpDhQxbRkr6CA+FFfr+/r96rWSDSgDenDQuSvPAKw==} resolution: {integrity: sha512-fKcAUPVFO3jfDKXCSDGY0MhZFF/wDtx3rgFnogWYu4knk38o9RaqRkvMvqJhLYPuWaEM5h6/z1dRrr9KKCbrVg==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [x64] cpu: [x64]
os: [darwin] os: [darwin]
@ -871,8 +871,8 @@ packages:
dev: true dev: true
optional: true optional: true
/@tauri-apps/cli-linux-arm-gnueabihf@1.2.3: /@tauri-apps/cli-linux-arm-gnueabihf@1.3.1:
resolution: {integrity: sha512-C7h5vqAwXzY0kRGSU00Fj8PudiDWFCiQqqUNI1N+fhCILrzWZB9TPBwdx33ZfXKt/U4+emdIoo/N34v3TiAOmQ==} resolution: {integrity: sha512-+4H0dv8ltJHYu/Ma1h9ixUPUWka9EjaYa8nJfiMsdCI4LJLNE6cPveE7RmhZ59v9GW1XB108/k083JUC/OtGvA==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [arm] cpu: [arm]
os: [linux] os: [linux]
@ -880,8 +880,8 @@ packages:
dev: true dev: true
optional: true optional: true
/@tauri-apps/cli-linux-arm64-gnu@1.2.3: /@tauri-apps/cli-linux-arm64-gnu@1.3.1:
resolution: {integrity: sha512-buf1c8sdkuUzVDkGPQpyUdAIIdn5r0UgXU6+H5fGPq/Xzt5K69JzXaeo6fHsZEZghbV0hOK+taKV4J0m30UUMQ==} resolution: {integrity: sha512-Pj3odVO1JAxLjYmoXKxcrpj/tPxcA8UP8N06finhNtBtBaxAjrjjxKjO4968KB0BUH7AASIss9EL4Tr0FGnDuw==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
@ -889,8 +889,8 @@ packages:
dev: true dev: true
optional: true optional: true
/@tauri-apps/cli-linux-arm64-musl@1.2.3: /@tauri-apps/cli-linux-arm64-musl@1.3.1:
resolution: {integrity: sha512-x88wPS9W5xAyk392vc4uNHcKBBvCp0wf4H9JFMF9OBwB7vfd59LbQCFcPSu8f0BI7bPrOsyHqspWHuFL8ojQEA==} resolution: {integrity: sha512-tA0JdDLPFaj42UDIVcF2t8V0tSha40rppcmAR/MfQpTCxih6399iMjwihz9kZE1n4b5O4KTq9GliYo50a8zYlQ==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
@ -898,8 +898,8 @@ packages:
dev: true dev: true
optional: true optional: true
/@tauri-apps/cli-linux-x64-gnu@1.2.3: /@tauri-apps/cli-linux-x64-gnu@1.3.1:
resolution: {integrity: sha512-ZMz1jxEVe0B4/7NJnlPHmwmSIuwiD6ViXKs8F+OWWz2Y4jn5TGxWKFg7DLx5OwQTRvEIZxxT7lXHi5CuTNAxKg==} resolution: {integrity: sha512-FDU+Mnvk6NLkqQimcNojdKpMN4Y3W51+SQl+NqG9AFCWprCcSg62yRb84751ujZuf2MGT8HQOfmd0i77F4Q3tQ==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
@ -907,8 +907,8 @@ packages:
dev: true dev: true
optional: true optional: true
/@tauri-apps/cli-linux-x64-musl@1.2.3: /@tauri-apps/cli-linux-x64-musl@1.3.1:
resolution: {integrity: sha512-B/az59EjJhdbZDzawEVox0LQu2ZHCZlk8rJf85AMIktIUoAZPFbwyiUv7/zjzA/sY6Nb58OSJgaPL2/IBy7E0A==} resolution: {integrity: sha512-MpO3akXFmK8lZYEbyQRDfhdxz1JkTBhonVuz5rRqxwA7gnGWHa1aF1+/2zsy7ahjB2tQ9x8DDFDMdVE20o9HrA==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
@ -916,8 +916,8 @@ packages:
dev: true dev: true
optional: true optional: true
/@tauri-apps/cli-win32-ia32-msvc@1.2.3: /@tauri-apps/cli-win32-ia32-msvc@1.3.1:
resolution: {integrity: sha512-ypdO1OdC5ugNJAKO2m3sb1nsd+0TSvMS9Tr5qN/ZSMvtSduaNwrcZ3D7G/iOIanrqu/Nl8t3LYlgPZGBKlw7Ng==} resolution: {integrity: sha512-9Boeo3K5sOrSBAZBuYyGkpV2RfnGQz3ZhGJt4hE6P+HxRd62lS6+qDKAiw1GmkZ0l1drc2INWrNeT50gwOKwIQ==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [ia32] cpu: [ia32]
os: [win32] os: [win32]
@ -925,8 +925,8 @@ packages:
dev: true dev: true
optional: true optional: true
/@tauri-apps/cli-win32-x64-msvc@1.2.3: /@tauri-apps/cli-win32-x64-msvc@1.3.1:
resolution: {integrity: sha512-CsbHQ+XhnV/2csOBBDVfH16cdK00gNyNYUW68isedmqcn8j+s0e9cQ1xXIqi+Hue3awp8g3ImYN5KPepf3UExw==} resolution: {integrity: sha512-wMrTo91hUu5CdpbElrOmcZEoJR4aooTG+fbtcc87SMyPGQy1Ux62b+ZdwLvL1sVTxnIm//7v6QLRIWGiUjCPwA==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [x64] cpu: [x64]
os: [win32] os: [win32]
@ -934,20 +934,20 @@ packages:
dev: true dev: true
optional: true optional: true
/@tauri-apps/cli@1.2.3: /@tauri-apps/cli@1.3.1:
resolution: {integrity: sha512-erxtXuPhMEGJPBtnhPILD4AjuT81GZsraqpFvXAmEJZ2p8P6t7MVBifCL8LznRknznM3jn90D3M8RNBP3wcXTw==} resolution: {integrity: sha512-o4I0JujdITsVRm3/0spfJX7FcKYrYV1DXJqzlWIn6IY25/RltjU6qbC1TPgVww3RsRX63jyVUTcWpj5wwFl+EQ==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
hasBin: true hasBin: true
optionalDependencies: optionalDependencies:
'@tauri-apps/cli-darwin-arm64': 1.2.3 '@tauri-apps/cli-darwin-arm64': 1.3.1
'@tauri-apps/cli-darwin-x64': 1.2.3 '@tauri-apps/cli-darwin-x64': 1.3.1
'@tauri-apps/cli-linux-arm-gnueabihf': 1.2.3 '@tauri-apps/cli-linux-arm-gnueabihf': 1.3.1
'@tauri-apps/cli-linux-arm64-gnu': 1.2.3 '@tauri-apps/cli-linux-arm64-gnu': 1.3.1
'@tauri-apps/cli-linux-arm64-musl': 1.2.3 '@tauri-apps/cli-linux-arm64-musl': 1.3.1
'@tauri-apps/cli-linux-x64-gnu': 1.2.3 '@tauri-apps/cli-linux-x64-gnu': 1.3.1
'@tauri-apps/cli-linux-x64-musl': 1.2.3 '@tauri-apps/cli-linux-x64-musl': 1.3.1
'@tauri-apps/cli-win32-ia32-msvc': 1.2.3 '@tauri-apps/cli-win32-ia32-msvc': 1.3.1
'@tauri-apps/cli-win32-x64-msvc': 1.2.3 '@tauri-apps/cli-win32-x64-msvc': 1.3.1
dev: true dev: true
/@types/parse-json@4.0.0: /@types/parse-json@4.0.0:

View File

@ -16,7 +16,7 @@ tauri-build = { version = "1.3", features = [] }
[dependencies] [dependencies]
gpcommon = { path = "../../gpcommon" } gpcommon = { path = "../../gpcommon" }
tauri = { version = "1.3", features = ["http-all", "window-data-url"] } tauri = { version = "1.3", features = ["http-all", "window-all", "window-data-url"] }
tauri-plugin-log = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v1", features = [ tauri-plugin-log = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v1", features = [
"colored", "colored",
] } ] }
@ -28,6 +28,7 @@ webkit2gtk = "0.18.2"
regex = "1" regex = "1"
url = "2.3" url = "2.3"
tokio = { version = "1.14", features = ["full"] } tokio = { version = "1.14", features = ["full"] }
veil = "0.1.6"
[features] [features]
# by default Tauri runs in production mode # by default Tauri runs in production mode

View File

@ -1,15 +1,18 @@
use crate::utils::{clear_webview_cookies, redact_url};
use log::{debug, info, warn}; use log::{debug, info, warn};
use regex::Regex; use regex::Regex;
use serde::de::Error;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::{sync::Arc, time::Duration}; use std::{sync::Arc, time::Duration};
use tauri::EventHandler; use tauri::EventHandler;
use tauri::{AppHandle, Manager, Window, WindowBuilder, WindowEvent::CloseRequested, WindowUrl}; use tauri::{AppHandle, Manager, Window, WindowEvent::CloseRequested, WindowUrl};
use tokio::sync::{mpsc, Mutex}; use tokio::sync::{mpsc, Mutex};
use tokio::time::timeout; use tokio::time::timeout;
use veil::Redact;
use webkit2gtk::gio::Cancellable; use webkit2gtk::gio::Cancellable;
use webkit2gtk::glib::GString; use webkit2gtk::glib::GString;
use webkit2gtk::traits::{URIResponseExt, WebViewExt}; use webkit2gtk::traits::{URIResponseExt, WebViewExt};
use webkit2gtk::{CookieManagerExt, LoadEvent, WebContextExt, WebResource, WebResourceExt}; use webkit2gtk::{LoadEvent, WebResource, WebResourceExt};
const AUTH_WINDOW_LABEL: &str = "auth_window"; const AUTH_WINDOW_LABEL: &str = "auth_window";
const AUTH_ERROR_EVENT: &str = "auth-error"; const AUTH_ERROR_EVENT: &str = "auth-error";
@ -28,10 +31,11 @@ pub(crate) enum SamlBinding {
Post, Post,
} }
#[derive(Debug, Clone, Deserialize)] #[derive(Redact, Clone, Deserialize)]
pub(crate) struct AuthRequest { pub(crate) struct AuthRequest {
#[serde(alias = "samlBinding")] #[serde(alias = "samlBinding")]
saml_binding: SamlBinding, saml_binding: SamlBinding,
#[redact(fixed = 10)]
#[serde(alias = "samlRequest")] #[serde(alias = "samlRequest")]
saml_request: String, saml_request: String,
} }
@ -49,14 +53,20 @@ impl TryFrom<Option<&str>> for AuthRequest {
type Error = serde_json::Error; type Error = serde_json::Error;
fn try_from(value: Option<&str>) -> Result<Self, Self::Error> { fn try_from(value: Option<&str>) -> Result<Self, Self::Error> {
serde_json::from_str(value.unwrap_or("{}")) match value {
Some(value) => serde_json::from_str(value),
None => Err(Error::custom("No auth request provided")),
}
} }
} }
#[derive(Debug, Clone, Serialize)] #[derive(Redact, Clone, Serialize)]
pub(crate) struct AuthData { pub(crate) struct AuthData {
#[redact]
username: Option<String>, username: Option<String>,
#[redact(fixed = 10)]
prelogin_cookie: Option<String>, prelogin_cookie: Option<String>,
#[redact(fixed = 10)]
portal_userauthcookie: Option<String>, portal_userauthcookie: Option<String>,
} }
@ -93,27 +103,35 @@ enum AuthEvent {
Cancel, Cancel,
} }
pub(crate) async fn saml_login( pub(crate) struct SamlLoginParams {
auth_request: AuthRequest, pub auth_request: AuthRequest,
ua: &str, pub user_agent: String,
clear_cookies: bool, pub clear_cookies: bool,
app_handle: &AppHandle, pub app_handle: AppHandle,
) -> tauri::Result<Option<AuthData>> { }
pub(crate) async fn saml_login(params: SamlLoginParams) -> tauri::Result<Option<AuthData>> {
info!("Starting SAML login"); info!("Starting SAML login");
let (event_tx, event_rx) = mpsc::channel::<AuthEvent>(8); let (event_tx, event_rx) = mpsc::channel::<AuthEvent>(8);
let window = build_window(app_handle, ua)?; let window = build_window(&params.app_handle, &params.user_agent)?;
setup_webview(&window, clear_cookies, event_tx.clone())?; setup_webview(&window, event_tx.clone())?;
let handler = setup_window(&window, event_tx); let handler = setup_window(&window, event_tx);
let result = process(&window, auth_request, event_rx).await; if params.clear_cookies {
if let Err(err) = clear_webview_cookies(&window).await {
warn!("Failed to clear webview cookies: {}", err);
}
}
let result = process(&window, params.auth_request, event_rx).await;
window.unlisten(handler); window.unlisten(handler);
result result
} }
fn build_window(app_handle: &AppHandle, ua: &str) -> tauri::Result<Window> { fn build_window(app_handle: &AppHandle, ua: &str) -> tauri::Result<Window> {
let url = WindowUrl::App("auth.html".into()); let url = WindowUrl::App("auth.html".into());
WindowBuilder::new(app_handle, AUTH_WINDOW_LABEL, url) Window::builder(app_handle, AUTH_WINDOW_LABEL, url)
.visible(false) .visible(false)
.title("GlobalProtect Login") .title("GlobalProtect Login")
.user_agent(ua) .user_agent(ua)
@ -124,19 +142,11 @@ fn build_window(app_handle: &AppHandle, ua: &str) -> tauri::Result<Window> {
} }
// Setup webview events // Setup webview events
fn setup_webview( fn setup_webview(window: &Window, event_tx: mpsc::Sender<AuthEvent>) -> tauri::Result<()> {
window: &Window,
clear_cookies: bool,
event_tx: mpsc::Sender<AuthEvent>,
) -> tauri::Result<()> {
window.with_webview(move |wv| { window.with_webview(move |wv| {
let wv = wv.inner(); let wv = wv.inner();
let event_tx_clone = event_tx.clone(); let event_tx_clone = event_tx.clone();
if clear_cookies {
clear_webview_cookies(&wv);
}
wv.connect_load_changed(move |wv, event| { wv.connect_load_changed(move |wv, event| {
if LoadEvent::Finished != event { if LoadEvent::Finished != event {
return; return;
@ -145,12 +155,11 @@ fn setup_webview(
let uri = wv.uri().unwrap_or("".into()); let uri = wv.uri().unwrap_or("".into());
// Empty URI indicates that an error occurred // Empty URI indicates that an error occurred
if uri.is_empty() { if uri.is_empty() {
warn!("Empty URI loaded"); warn!("Empty URI loaded, retrying");
send_auth_error(&event_tx_clone, AuthError::TokenInvalid); send_auth_error(event_tx_clone.clone(), AuthError::TokenInvalid);
return; return;
} }
// TODO, redact URI info!("Loaded URI: {}", redact_url(&uri));
debug!("Loaded URI: {}", uri);
if let Some(main_res) = wv.main_resource() { if let Some(main_res) = wv.main_resource() {
parse_auth_data(&main_res, event_tx_clone.clone()); parse_auth_data(&main_res, event_tx_clone.clone());
@ -161,7 +170,7 @@ fn setup_webview(
wv.connect_load_failed(move |_wv, event, _uri, err| { wv.connect_load_failed(move |_wv, event, _uri, err| {
warn!("Load failed: {:?}, {:?}", event, err); warn!("Load failed: {:?}, {:?}", event, err);
send_auth_error(&event_tx, AuthError::TokenInvalid); send_auth_error(event_tx.clone(), AuthError::TokenInvalid);
false false
}); });
}) })
@ -170,20 +179,17 @@ fn setup_webview(
fn setup_window(window: &Window, event_tx: mpsc::Sender<AuthEvent>) -> EventHandler { fn setup_window(window: &Window, event_tx: mpsc::Sender<AuthEvent>) -> EventHandler {
let event_tx_clone = event_tx.clone(); let event_tx_clone = event_tx.clone();
window.on_window_event(move |event| { window.on_window_event(move |event| {
if let CloseRequested { api, .. } = event { if let CloseRequested { .. } = event {
api.prevent_close(); send_auth_event(event_tx_clone.clone(), AuthEvent::Cancel);
send_auth_event(&event_tx_clone, AuthEvent::Cancel);
} }
}); });
window.listen_global(AUTH_REQUEST_EVENT, move |event| { window.listen_global(AUTH_REQUEST_EVENT, move |event| {
if let Ok(payload) = TryInto::<AuthRequest>::try_into(event.payload()) { if let Ok(payload) = TryInto::<AuthRequest>::try_into(event.payload()) {
let event_tx = event_tx.clone(); let event_tx = event_tx.clone();
let _ = tokio::spawn(async move { send_auth_event(event_tx.clone(), AuthEvent::Request(payload));
if let Err(err) = event_tx.send(AuthEvent::Request(payload)).await { } else {
warn!("Error sending event: {}", err); warn!("Invalid auth request payload");
}
});
} }
}) })
} }
@ -199,7 +205,10 @@ async fn process(
let handle = tokio::spawn(show_window_after_timeout(window.clone())); let handle = tokio::spawn(show_window_after_timeout(window.clone()));
let auth_data = process_auth_event(&window, event_rx).await; let auth_data = process_auth_event(&window, event_rx).await;
handle.abort();
if !handle.is_finished() {
handle.abort();
}
Ok(auth_data) Ok(auth_data)
} }
@ -255,7 +264,6 @@ async fn process_auth_event(
} }
AuthEvent::Cancel => { AuthEvent::Cancel => {
info!("User cancelled the authentication process, closing window"); info!("User cancelled the authentication process, closing window");
close_window(window);
return None; return None;
} }
AuthEvent::Error(AuthError::TokenInvalid) => { AuthEvent::Error(AuthError::TokenInvalid) => {
@ -292,19 +300,19 @@ async fn process_auth_event(
/// we should show the window after a short timeout, it will be cancelled if the /// we should show the window after a short timeout, it will be cancelled if the
/// token is found in the response, no matter it's valid or not. /// token is found in the response, no matter it's valid or not.
async fn handle_token_not_found(window: Window, cancel_timeout_rx: Arc<Mutex<mpsc::Receiver<()>>>) { async fn handle_token_not_found(window: Window, cancel_timeout_rx: Arc<Mutex<mpsc::Receiver<()>>>) {
match cancel_timeout_rx.try_lock() { if let Ok(mut cancel_timeout_rx) = cancel_timeout_rx.try_lock() {
Ok(mut cancel_timeout_rx) => { let duration = Duration::from_secs(SHOW_WINDOW_TIMEOUT);
let duration = Duration::from_secs(SHOW_WINDOW_TIMEOUT); if timeout(duration, cancel_timeout_rx.recv()).await.is_err() {
if let Err(_) = timeout(duration, cancel_timeout_rx.recv()).await { info!(
info!("Timeout expired, showing window"); "Timeout expired after {} seconds, showing window",
show_window(&window); SHOW_WINDOW_TIMEOUT
} else { );
info!("Showing window timeout cancelled"); show_window(&window);
} } else {
} info!("Showing window timeout cancelled");
Err(_) => {
debug!("Window will be shown by another task, skipping");
} }
} else {
debug!("Window will be shown by another task, skipping");
} }
} }
@ -314,7 +322,7 @@ fn parse_auth_data(main_res: &WebResource, event_tx: mpsc::Sender<AuthEvent>) {
if let Some(response) = main_res.response() { if let Some(response) = main_res.response() {
if let Some(auth_data) = read_auth_data_from_response(&response) { if let Some(auth_data) = read_auth_data_from_response(&response) {
debug!("Got auth data from HTTP headers: {:?}", auth_data); debug!("Got auth data from HTTP headers: {:?}", auth_data);
send_auth_data(&event_tx, auth_data); send_auth_data(event_tx, auth_data);
return; return;
} }
} }
@ -326,11 +334,11 @@ fn parse_auth_data(main_res: &WebResource, event_tx: mpsc::Sender<AuthEvent>) {
match read_auth_data_from_html(&html) { match read_auth_data_from_html(&html) {
Ok(auth_data) => { Ok(auth_data) => {
debug!("Got auth data from HTML: {:?}", auth_data); debug!("Got auth data from HTML: {:?}", auth_data);
send_auth_data(&event_tx, auth_data); send_auth_data(event_tx, auth_data);
} }
Err(err) => { Err(err) => {
debug!("Error reading auth data from HTML: {:?}", err); debug!("Error reading auth data from HTML: {:?}", err);
send_auth_error(&event_tx, err); send_auth_error(event_tx, err);
} }
} }
} }
@ -387,18 +395,20 @@ fn parse_xml_tag(html: &str, tag: &str) -> Option<String> {
.map(|m| m.as_str().to_string()) .map(|m| m.as_str().to_string())
} }
fn send_auth_data(event_tx: &mpsc::Sender<AuthEvent>, auth_data: AuthData) { fn send_auth_data(event_tx: mpsc::Sender<AuthEvent>, auth_data: AuthData) {
send_auth_event(event_tx, AuthEvent::Success(auth_data)); send_auth_event(event_tx, AuthEvent::Success(auth_data));
} }
fn send_auth_error(event_tx: &mpsc::Sender<AuthEvent>, err: AuthError) { fn send_auth_error(event_tx: mpsc::Sender<AuthEvent>, err: AuthError) {
send_auth_event(event_tx, AuthEvent::Error(err)); send_auth_event(event_tx, AuthEvent::Error(err));
} }
fn send_auth_event(event_tx: &mpsc::Sender<AuthEvent>, auth_event: AuthEvent) { fn send_auth_event(event_tx: mpsc::Sender<AuthEvent>, auth_event: AuthEvent) {
if let Err(err) = event_tx.blocking_send(auth_event) { let _ = tauri::async_runtime::spawn(async move {
warn!("Error sending event: {}", err) if let Err(err) = event_tx.send(auth_event).await {
} warn!("Error sending event: {}", err);
}
});
} }
fn show_window(window: &Window) { fn show_window(window: &Window) {
@ -418,15 +428,3 @@ fn close_window(window: &Window) {
warn!("Error closing window: {}", err); warn!("Error closing window: {}", err);
} }
} }
fn clear_webview_cookies(wv: &webkit2gtk::WebView) {
if let Some(context) = wv.context() {
if let Some(cookie_manager) = context.cookie_manager() {
#[allow(deprecated)]
cookie_manager.delete_all_cookies();
info!("Cookies cleared");
return;
}
}
warn!("No cookie manager found");
}

View File

@ -1,4 +1,4 @@
use crate::auth::{self, AuthData, AuthRequest, SamlBinding}; use crate::auth::{self, AuthData, AuthRequest, SamlBinding, SamlLoginParams};
use gpcommon::{Client, ServerApiError, VpnStatus}; use gpcommon::{Client, ServerApiError, VpnStatus};
use std::sync::Arc; use std::sync::Arc;
use tauri::{AppHandle, State}; use tauri::{AppHandle, State};
@ -32,13 +32,13 @@ pub(crate) async fn saml_login(
request: String, request: String,
app_handle: AppHandle, app_handle: AppHandle,
) -> tauri::Result<Option<AuthData>> { ) -> tauri::Result<Option<AuthData>> {
let ua = "PAN GlobalProtect"; let user_agent = String::from("PAN GlobalProtect");
let clear_cookies = false; let clear_cookies = false;
auth::saml_login( let params = SamlLoginParams {
AuthRequest::new(binding, request), auth_request: AuthRequest::new(binding, request),
ua, user_agent,
clear_cookies, clear_cookies,
&app_handle, app_handle,
) };
.await auth::saml_login(params).await
} }

View File

@ -13,6 +13,7 @@ use tauri_plugin_log::LogTarget;
mod auth; mod auth;
mod commands; mod commands;
mod utils;
#[derive(Debug, Clone, Serialize)] #[derive(Debug, Clone, Serialize)]
struct StatusPayload { struct StatusPayload {

View File

@ -0,0 +1,88 @@
use log::{info, warn};
use std::time::Instant;
use tauri::Window;
use tokio::sync::oneshot;
use url::{form_urlencoded, Url};
use webkit2gtk::{
gio::Cancellable, glib::TimeSpan, WebContextExt, WebViewExt, WebsiteDataManagerExtManual,
WebsiteDataTypes,
};
pub(crate) fn redact_url(url: &str) -> String {
if let Ok(mut url) = Url::parse(&url) {
if let Err(err) = url.set_host(Some("redacted")) {
warn!("Error redacting URL: {}", err);
}
let query = url.query().unwrap_or_default();
if !query.is_empty() {
// Replace the query value with <redacted> for each key.
let redacted_query = redact_query(url.query().unwrap_or(""));
url.set_query(Some(&redacted_query));
}
return url.to_string();
} else {
warn!("Error parsing URL: {}", url);
url.to_string()
}
}
fn redact_query(query: &str) -> String {
let query_pairs = form_urlencoded::parse(query.as_bytes());
let mut redacted_pairs = query_pairs.map(|(key, _)| (key, "__redacted__"));
form_urlencoded::Serializer::new(String::new())
.extend_pairs(redacted_pairs.by_ref())
.finish()
}
pub(crate) async fn clear_webview_cookies(window: &Window) -> Result<(), tauri::Error> {
let (tx, rx) = oneshot::channel::<()>();
window.with_webview(|wv| {
let wv = wv.inner();
let context = match wv.context() {
Some(context) => context,
None => {
return send_error(tx, "No context found");
}
};
let data_manager = match context.website_data_manager() {
Some(manager) => manager,
None => {
return send_error(tx, "No data manager found");
}
};
let now = Instant::now();
data_manager.clear(
WebsiteDataTypes::COOKIES,
TimeSpan(0),
Cancellable::NONE,
move |result| match result {
Err(err) => {
send_error(tx, &err.to_string());
}
Ok(_) => {
info!("Cookies cleared in {} ms", now.elapsed().as_millis());
send_result(tx);
}
},
);
})?;
rx.await.map_err(|_| tauri::Error::FailedToSendMessage)
}
fn send_error(tx: oneshot::Sender<()>, message: &str) {
warn!("Error clearing cookies: {}", message);
if tx.send(()).is_err() {
warn!("Error sending clear cookies result");
}
}
fn send_result(tx: oneshot::Sender<()>) {
if tx.send(()).is_err() {
warn!("Error sending clear cookies result");
}
}

View File

@ -16,6 +16,9 @@
"all": true, "all": true,
"request": true, "request": true,
"scope": ["https://**"] "scope": ["https://**"]
},
"window": {
"all": true
} }
}, },
"bundle": { "bundle": {

View File

@ -1,6 +1,7 @@
import { WebviewWindow } from "@tauri-apps/api/window";
import { Box, TextField } from "@mui/material"; import { Box, TextField } from "@mui/material";
import Button from "@mui/material/Button"; import Button from "@mui/material/Button";
import { ChangeEvent, FormEvent, useEffect, useState } from "react"; import { ChangeEvent, FormEvent, useEffect, useRef, useState } from "react";
import "./App.css"; import "./App.css";
import ConnectionStatus, { Status } from "./components/ConnectionStatus"; import ConnectionStatus, { Status } from "./components/ConnectionStatus";
@ -13,6 +14,7 @@ import gatewayService from "./services/gatewayService";
import portalService from "./services/portalService"; import portalService from "./services/portalService";
import vpnService from "./services/vpnService"; import vpnService from "./services/vpnService";
import authService from "./services/authService"; import authService from "./services/authService";
import { Maybe } from "./types";
export default function App() { export default function App() {
const [portalAddress, setPortalAddress] = useState("vpn.microstrategy.com"); // useState("220.191.185.154"); const [portalAddress, setPortalAddress] = useState("vpn.microstrategy.com"); // useState("220.191.185.154");
@ -25,6 +27,7 @@ export default function App() {
open: false, open: false,
message: "", message: "",
}); });
const regionRef = useRef<Maybe<string>>(null);
useEffect(() => { useEffect(() => {
return vpnService.onStatusChanged((latestStatus) => { return vpnService.onStatusChanged((latestStatus) => {
@ -75,6 +78,8 @@ export default function App() {
try { try {
const response = await portalService.prelogin(portalAddress); const response = await portalService.prelogin(portalAddress);
const { region } = response;
regionRef.current = region;
if (portalService.isSamlAuth(response)) { if (portalService.isSamlAuth(response)) {
const { samlAuthMethod, samlAuthRequest } = response; const { samlAuthMethod, samlAuthRequest } = response;
@ -97,6 +102,22 @@ export default function App() {
); );
console.log("portalConfigResponse", portalConfigResponse); console.log("portalConfigResponse", portalConfigResponse);
const { gateways, userAuthCookie, prelogonUserAuthCookie } =
portalConfigResponse;
const preferredGateway = portalService.preferredGateway(
gateways,
regionRef.current
);
const token = await gatewayService.login(preferredGateway, {
user: authData.username,
userAuthCookie,
prelogonUserAuthCookie,
});
await vpnService.connect(preferredGateway.address!, token);
} else if (portalService.isPasswordAuth(response)) { } else if (portalService.isPasswordAuth(response)) {
setPasswordAuthOpen(true); setPasswordAuthOpen(true);
setPasswordAuth({ setPasswordAuth({
@ -146,16 +167,18 @@ export default function App() {
{ user, passwd } { user, passwd }
); );
const { gateways, preferredGateway, userAuthCookie } = const { gateways, userAuthCookie } = portalConfigResponse;
portalConfigResponse;
if (gateways.length === 0) { if (gateways.length === 0) {
// TODO handle no gateways, treat the portal as a gateway // TODO handle no gateways, treat the portal as a gateway
throw new Error("No gateways found"); throw new Error("No gateways found");
} }
const token = await gatewayService.login({ const preferredGateway = portalService.preferredGateway(
gateway: preferredGateway, gateways,
regionRef.current
);
const token = await gatewayService.login(preferredGateway, {
user, user,
passwd, passwd,
userAuthCookie, userAuthCookie,

View File

@ -1,23 +1,42 @@
import { Body, ResponseType, fetch } from "@tauri-apps/api/http"; import { Body, ResponseType, fetch } from "@tauri-apps/api/http";
import { Maybe } from "../types";
import { parseXml } from "../utils/parseXml"; import { parseXml } from "../utils/parseXml";
import { Gateway } from "./types"; import { Gateway } from "./types";
type LoginParams = { type LoginParams = {
gateway: Gateway;
user: string; user: string;
passwd: string; passwd?: string | null;
userAuthCookie: Maybe<string>; userAuthCookie?: string | null;
prelogonUserAuthCookie?: string | null;
}; };
class GatewayService { class GatewayService {
async login(params: LoginParams) { async login(gateway: Gateway, params: LoginParams) {
const { gateway, user, passwd, userAuthCookie } = params; const { user, passwd, userAuthCookie, prelogonUserAuthCookie } = params;
if (!gateway.address) { if (!gateway.address) {
throw new Error("Gateway address is required"); throw new Error("Gateway address is required");
} }
const loginUrl = `https://${gateway.address}/ssl-vpn/login.esp`; const loginUrl = `https://${gateway.address}/ssl-vpn/login.esp`;
const body = Body.form({
prot: "https:",
inputStr: "",
jnlpReady: "jnlpReady",
computer: "Linux", // TODO
ok: "Login",
direct: "yes",
"ipv6-support": "yes",
clientVer: "4100",
clientos: "Linux",
"os-version": "Linux",
server: gateway.address,
user,
passwd: passwd || "",
"prelogin-cookie": "",
"portal-userauthcookie": userAuthCookie || "",
"portal-prelogonuserauthcookie": prelogonUserAuthCookie || "",
});
console.log("Login body", body);
const response = await fetch<string>(loginUrl, { const response = await fetch<string>(loginUrl, {
method: "POST", method: "POST",
@ -25,24 +44,7 @@ class GatewayService {
"User-Agent": "PAN GlobalProtect", "User-Agent": "PAN GlobalProtect",
}, },
responseType: ResponseType.Text, responseType: ResponseType.Text,
body: Body.form({ body,
prot: "https:",
inputStr: "",
jnlpReady: "jnlpReady",
computer: "Linux", // TODO
ok: "Login",
direct: "yes",
"ipv6-support": "yes",
clientVer: "4100",
clientos: "Linux",
"os-version": "Linux",
server: gateway.address,
user,
passwd,
"portal-userauthcookie": userAuthCookie ?? "",
"portal-prelogonuserauthcookie": "",
"prelogin-cookie": "",
}),
}); });
if (!response.ok) { if (!response.ok) {

View File

@ -25,15 +25,9 @@ type PreloginResponse = MaybeProperties<
type ConfigResponse = { type ConfigResponse = {
userAuthCookie: Maybe<string>; userAuthCookie: Maybe<string>;
prelogonUserAuthCookie: Maybe<string>; prelogonUserAuthCookie: Maybe<string>;
preferredGateway: Gateway;
gateways: Gateway[]; gateways: Gateway[];
}; };
// user: username,
// passwd: password,
// "prelogin-cookie": "",
// "portal-userauthcookie": "",
// "portal-prelogonuserauthcookie": "",
type PortalConfigParams = { type PortalConfigParams = {
user: string; user: string;
passwd?: string | null; passwd?: string | null;
@ -81,10 +75,7 @@ class PortalService {
} }
isSamlAuth(response: PreloginResponse): response is SamlPreloginResponse { isSamlAuth(response: PreloginResponse): response is SamlPreloginResponse {
if (response.samlAuthMethod && response.samlAuthRequest) { return !!(response.samlAuthMethod && response.samlAuthRequest);
return true;
}
return false;
} }
isPasswordAuth( isPasswordAuth(
@ -143,6 +134,8 @@ class PortalService {
} }
private parsePortalConfigResponse(response: string): ConfigResponse { private parsePortalConfigResponse(response: string): ConfigResponse {
console.log(response);
const result = parseXml(response); const result = parseXml(response);
const gateways = result.all("gateways list > entry").map((entry) => { const gateways = result.all("gateways list > entry").map((entry) => {
const address = entry.attr("name"); const address = entry.attr("name");
@ -152,13 +145,13 @@ class PortalService {
return { return {
name, name,
address, address,
priority: priority ? parseInt(priority, 10) : undefined, priority: priority ? parseInt(priority, 10) : Infinity,
priorityRules: entry.all("priority-rule > entry").map((entry) => { priorityRules: entry.all("priority-rule > entry").map((entry) => {
const name = entry.attr("name"); const name = entry.attr("name");
const priority = entry.text("priority"); const priority = entry.text("priority");
return { return {
name, name,
priority: priority ? parseInt(priority, 10) : undefined, priority: priority ? parseInt(priority, 10) : Infinity,
}; };
}), }),
}; };
@ -167,10 +160,37 @@ class PortalService {
return { return {
userAuthCookie: result.text("portal-userauthcookie"), userAuthCookie: result.text("portal-userauthcookie"),
prelogonUserAuthCookie: result.text("portal-prelogonuserauthcookie"), prelogonUserAuthCookie: result.text("portal-prelogonuserauthcookie"),
preferredGateway: gateways[0],
gateways, gateways,
}; };
} }
preferredGateway(gateways: Gateway[], region: Maybe<string>) {
console.log(gateways);
let defaultGateway = gateways[0];
for (const gateway of gateways) {
if (gateway.priority < defaultGateway.priority) {
defaultGateway = gateway;
}
}
if (!region) {
return defaultGateway;
}
let preferredGateway = defaultGateway;
let currentPriority = Infinity;
for (const gateway of gateways) {
const priorityRule = gateway.priorityRules.find(
({ name }) => name === region
);
if (priorityRule && priorityRule.priority < currentPriority) {
preferredGateway = gateway;
currentPriority = priorityRule.priority;
}
}
return preferredGateway;
}
} }
export default new PortalService(); export default new PortalService();

View File

@ -2,12 +2,12 @@ import { Maybe } from '../types';
type PriorityRule = { type PriorityRule = {
name: Maybe<string>; name: Maybe<string>;
priority: Maybe<number>; priority: number;
}; };
export type Gateway = { export type Gateway = {
name: Maybe<string>; name: Maybe<string>;
address: Maybe<string>; address: Maybe<string>;
priorityRules: PriorityRule[]; priorityRules: PriorityRule[];
priority: Maybe<number>; priority: number;
}; };