From 54d3bb8a92d84dbd0a4847010650171640febec0 Mon Sep 17 00:00:00 2001 From: Kevin Yue Date: Sun, 28 May 2023 14:04:37 +0800 Subject: [PATCH] refactor: refine auth window --- .vscode/settings.json | 1 + Cargo.lock | 1 + gpgui/src-tauri/Cargo.toml | 1 + gpgui/src-tauri/src/auth.rs | 452 ++++++++++++++++++------------ gpgui/src-tauri/src/main.rs | 12 +- gpgui/src/App.tsx | 11 +- gpgui/src/services/authService.ts | 12 +- 7 files changed, 301 insertions(+), 189 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index 8d8fab5..c238f65 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -8,6 +8,7 @@ "prelogon", "prelogonuserauthcookie", "tauri", + "unlisten", "userauthcookie", "vpninfo" ], diff --git a/Cargo.lock b/Cargo.lock index cb9354c..61a72d0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -60,6 +60,7 @@ dependencies = [ "tauri", "tauri-build", "tauri-plugin-log", + "tokio", "url", "webkit2gtk", ] diff --git a/gpgui/src-tauri/Cargo.toml b/gpgui/src-tauri/Cargo.toml index bceb869..58223d2 100644 --- a/gpgui/src-tauri/Cargo.toml +++ b/gpgui/src-tauri/Cargo.toml @@ -25,6 +25,7 @@ env_logger = "0.10" webkit2gtk = "0.18.2" regex = "1" url = "2.3" +tokio = { version = "1.14", features = ["full"] } [features] # by default Tauri runs in production mode diff --git a/gpgui/src-tauri/src/auth.rs b/gpgui/src-tauri/src/auth.rs index d318a6b..3032731 100644 --- a/gpgui/src-tauri/src/auth.rs +++ b/gpgui/src-tauri/src/auth.rs @@ -1,20 +1,21 @@ +use log::{debug, warn}; use regex::Regex; use serde::{Deserialize, Serialize}; -use std::sync::{Arc}; -use tauri::{AppHandle, Manager, WindowBuilder, WindowEvent::CloseRequested, WindowUrl}; -use url::Url; +use std::{sync::Arc, time::Duration}; +use tauri::EventHandler; +use tauri::{AppHandle, Manager, Window, WindowBuilder, WindowEvent::CloseRequested, WindowUrl}; +use tokio::sync::{mpsc, Mutex}; +use tokio::time::timeout; use webkit2gtk::{ gio::Cancellable, glib::GString, traits::WebViewExt, LoadEvent, URIResponseExt, WebResource, WebResourceExt, }; const AUTH_WINDOW_LABEL: &str = "auth_window"; -const AUTH_SUCCESS_EVENT: &str = "auth-success"; const AUTH_ERROR_EVENT: &str = "auth-error"; -const AUTH_CANCEL_EVENT: &str = "auth-cancel"; const AUTH_REQUEST_EVENT: &str = "auth-request"; -#[derive(Debug, Deserialize)] +#[derive(Debug, Clone, Deserialize)] pub(crate) enum SamlBinding { #[serde(rename = "REDIRECT")] Redirect, @@ -22,30 +23,33 @@ pub(crate) enum SamlBinding { Post, } -pub(crate) struct AuthOptions { +#[derive(Debug, Clone, Deserialize)] +pub(crate) struct AuthRequest { + #[serde(alias = "samlBinding")] saml_binding: SamlBinding, - saml_request: String, - user_agent: String, -} - -#[derive(Debug, Deserialize)] -struct AuthRequestPayload { #[serde(alias = "samlRequest")] saml_request: String, } -impl AuthOptions { - pub fn new(saml_binding: SamlBinding, saml_request: String, user_agent: String) -> Self { +impl AuthRequest { + pub fn new(saml_binding: SamlBinding, saml_request: String) -> Self { Self { saml_binding, saml_request, - user_agent, } } } +impl TryFrom> for AuthRequest { + type Error = serde_json::Error; + + fn try_from(value: Option<&str>) -> Result { + serde_json::from_str(value.unwrap_or("{}")) + } +} + #[derive(Debug, Clone, Serialize)] -pub struct AuthData { +pub(crate) struct AuthData { username: Option, prelogin_cookie: Option, portal_userauthcookie: Option, @@ -72,183 +76,261 @@ impl AuthData { #[derive(Debug)] enum AuthError { - NotFound, - Invalid, + TokenNotFound, + TokenInvalid, } #[derive(Debug)] -struct AuthEventEmitter { - app_handle: AppHandle, +enum AuthEvent { + Request(AuthRequest), + Success(AuthData), + Error(AuthError), + Cancel, } -impl AuthEventEmitter { - fn new(app_handle: AppHandle) -> Self { - Self { app_handle } - } +pub(crate) async fn saml_login( + auth_request: AuthRequest, + ua: &str, + app_handle: &AppHandle, +) -> tauri::Result> { + let (event_tx, event_rx) = mpsc::channel::(8); + let window = build_window(app_handle, ua)?; + setup_webview(&window, event_tx.clone())?; + let handler_id = setup_window(&window, event_tx); - fn emit_success(&self, saml_result: AuthData) { - self.app_handle.emit_all(AUTH_SUCCESS_EVENT, saml_result); - if let Some(window) = self.app_handle.get_window(AUTH_WINDOW_LABEL) { - window.close(); + match process(&window, event_rx, auth_request).await { + Ok(auth_data) => { + window.unlisten(handler_id); + Ok(auth_data) } - } - - fn emit_error(&self, error: String) { - self.app_handle.emit_all(AUTH_ERROR_EVENT, error); - } - - fn emit_cancel(&self) { - self.app_handle.emit_all(AUTH_CANCEL_EVENT, ()); - } -} - -#[derive(Debug)] -pub(crate) struct AuthWindow { - event_emitter: Arc, - app_handle: AppHandle, - saml_binding: SamlBinding, - user_agent: String, -} - -impl AuthWindow { - pub fn new(app_handle: AppHandle, saml_binding: SamlBinding, user_agent: String) -> Self { - Self { - event_emitter: Arc::new(AuthEventEmitter::new(app_handle.clone())), - app_handle, - saml_binding, - user_agent, - } - } - - pub fn process(&self, saml_request: String) -> tauri::Result<()> { - let url = self.window_url(&saml_request)?; - let window = WindowBuilder::new(&self.app_handle, AUTH_WINDOW_LABEL, url) - .title("GlobalProtect Login") - .user_agent(&self.user_agent) - .always_on_top(true) - .focused(true) - .center() - .build()?; - - let event_emitter = self.event_emitter.clone(); - let is_post = matches!(self.saml_binding, SamlBinding::Post); - - window.with_webview(move |wv| { - let wv = wv.inner(); - // Load SAML request as HTML if POST binding is used - if is_post { - wv.load_html(&saml_request, None); - } - wv.connect_load_changed(move |wv, event| { - if LoadEvent::Finished == event { - if let Some(uri) = wv.uri() { - if uri.is_empty() { - println!("Empty URI"); - event_emitter.emit_error("Empty URI".to_string()); - return; - } else { - println!("Loaded URI: {}", uri); - } - } - - if let Some(main_res) = wv.main_resource() { - AuthResultParser::new(&event_emitter).parse(&main_res); - } - } - }); - })?; - - let event_emitter = self.event_emitter.clone(); - window.on_window_event(move |event| { - if let CloseRequested { .. } = event { - event_emitter.emit_cancel(); - } - }); - - let window_clone = window.clone(); - window.listen_global(AUTH_REQUEST_EVENT, move |event| { - let auth_request_payload: AuthRequestPayload = serde_json::from_str(event.payload().unwrap()).unwrap(); - let saml_request = auth_request_payload.saml_request; - - window_clone.with_webview(move |wv| { - let wv = wv.inner(); - if is_post { - // Load SAML request as HTML if POST binding is used - wv.load_html(&saml_request, None); - } else { - println!("Redirecting to SAML request URL: {}", saml_request); - // Redirect to SAML request URL if REDIRECT binding is used - wv.load_uri(&saml_request); - } - }); - }); - - Ok(()) - } - - fn window_url(&self, saml_request: &String) -> tauri::Result { - match self.saml_binding { - SamlBinding::Redirect => match Url::parse(saml_request) { - Ok(url) => Ok(WindowUrl::External(url)), - Err(err) => Err(tauri::Error::InvalidUrl(err)), - }, - SamlBinding::Post => Ok(WindowUrl::App("auth.html".into())), + Err(err) => { + window.unlisten(handler_id); + Err(err) } } } -struct AuthResultParser<'a> { - event_emitter: &'a Arc, +fn build_window(app_handle: &AppHandle, ua: &str) -> tauri::Result { + let url = WindowUrl::App("auth.html".into()); + WindowBuilder::new(app_handle, AUTH_WINDOW_LABEL, url) + .visible(false) + .title("GlobalProtect Login") + .user_agent(ua) + .always_on_top(true) + .focused(true) + .center() + .build() } -impl<'a> AuthResultParser<'a> { - fn new(event_emitter: &'a Arc) -> Self { - Self { event_emitter } - } +fn setup_webview(window: &Window, event_tx: mpsc::Sender) -> tauri::Result<()> { + window.with_webview(move |wv| { + let wv = wv.inner(); + let event_tx = event_tx.clone(); - fn parse(&self, main_res: &WebResource) { - if let Some(response) = main_res.response() { - if let Some(saml_result) = read_auth_result_from_response(&response) { - // Got SAML result from HTTP headers - println!("SAML result: {:?}", saml_result); - self.event_emitter.emit_success(saml_result); + wv.connect_load_changed(move |wv, event| { + if LoadEvent::Finished != event { + debug!("Skipping load event: {:?}", event); return; } - } - let event_emitter = self.event_emitter.clone(); - main_res.data(Cancellable::NONE, move |data| { - if let Ok(data) = data { - let html = String::from_utf8_lossy(&data); - match read_auth_result_from_html(&html) { - Ok(saml_result) => { - // Got SAML result from HTML - println!("SAML result: {:?}", saml_result); - event_emitter.emit_success(saml_result); - return; - } - Err(AuthError::Invalid) => { - // Invalid SAML result - println!("Invalid SAML result"); - event_emitter.emit_error("Invalid SAML result".to_string()) - } - Err(AuthError::NotFound) => { - let has_form = html.contains(""); - if has_form { - // SAML form found - println!("SAML form found"); - } else { - // No SAML form found - println!("No SAML form found"); - } - }, + let uri = wv.uri().unwrap_or("".into()); + // Empty URI indicates that an error occurred + if uri.is_empty() { + warn!("Empty URI"); + if let Err(err) = event_tx.blocking_send(AuthEvent::Error(AuthError::TokenInvalid)) + { + println!("Error sending event: {}", err); } + return; + } + // TODO, redact URI + debug!("Loaded URI: {}", uri); + + if let Some(main_res) = wv.main_resource() { + // AuthDataParser::new(&window_tx_clone).parse(&main_res); + parse_auth_data(&main_res, event_tx.clone()); + } else { + warn!("No main_resource"); } }); + + wv.connect_load_failed(|_wv, event, err_msg, err| { + println!("Load failed: {:?}, {}, {:?}", event, err_msg, err); + false + }); + }) +} + +fn setup_window(window: &Window, event_tx: mpsc::Sender) -> EventHandler { + let event_tx_clone = event_tx.clone(); + window.on_window_event(move |event| { + if let CloseRequested { api, .. } = event { + api.prevent_close(); + if let Err(err) = event_tx_clone.blocking_send(AuthEvent::Cancel) { + println!("Error sending event: {}", err) + } + } + }); + + window.open_devtools(); + + window.listen_global(AUTH_REQUEST_EVENT, move |event| { + if let Ok(payload) = TryInto::::try_into(event.payload()) { + debug!("---------Received auth request"); + + let event_tx = event_tx.clone(); + let _ = tokio::spawn(async move { + if let Err(err) = event_tx.send(AuthEvent::Request(payload)).await { + warn!("Error sending event: {}", err); + } + }); + } + }) +} + +async fn process( + window: &Window, + event_rx: mpsc::Receiver, + auth_request: AuthRequest, +) -> tauri::Result> { + process_request(window, auth_request)?; + + let (close_tx, close_rx) = mpsc::channel::<()>(1); + + tokio::spawn(show_window_after_timeout(window.clone(), close_rx)); + process_auth_event(&window, event_rx, close_tx).await +} + +fn process_request(window: &Window, auth_request: AuthRequest) -> tauri::Result<()> { + let saml_request = auth_request.saml_request; + let is_post = matches!(auth_request.saml_binding, SamlBinding::Post); + + window.with_webview(move |wv| { + let wv = wv.inner(); + if is_post { + // Load SAML request as HTML if POST binding is used + wv.load_html(&saml_request, None); + } else { + println!("Redirecting to SAML request URL: {}", saml_request); + // Redirect to SAML request URL if REDIRECT binding is used + wv.load_uri(&saml_request); + } + }) +} + +async fn show_window_after_timeout(window: Window, mut close_rx: mpsc::Receiver<()>) { + // Show the window after 10 seconds + let duration = Duration::from_secs(10); + if let Err(_) = timeout(duration, close_rx.recv()).await { + println!("Final show window"); + show_window(&window); + } else { + println!("Window closed, cancel the final show window"); } } -fn read_auth_result_from_response(response: &webkit2gtk::URIResponse) -> Option { +async fn process_auth_event( + window: &Window, + mut event_rx: mpsc::Receiver, + close_tx: mpsc::Sender<()>, +) -> tauri::Result> { + let (cancel_timeout_tx, cancel_timeout_rx) = mpsc::channel::<()>(1); + let cancel_timeout_rx = Arc::new(Mutex::new(cancel_timeout_rx)); + + async fn close_window(window: &Window, close_tx: mpsc::Sender<()>) { + if let Err(err) = window.close() { + println!("Error closing window: {}", err); + } + if let Err(err) = close_tx.send(()).await { + warn!("Error sending the close event: {:?}", err); + } + } + + loop { + if let Some(auth_event) = event_rx.recv().await { + match auth_event { + AuthEvent::Request(auth_request) => { + println!("Got auth request: {:?}", auth_request); + process_request(&window, auth_request)?; + } + AuthEvent::Success(auth_data) => { + close_window(window, close_tx).await; + return Ok(Some(auth_data)); + } + AuthEvent::Cancel => { + close_window(window, close_tx).await; + return Ok(None); + } + AuthEvent::Error(AuthError::TokenInvalid) => { + if let Err(err) = cancel_timeout_tx.send(()).await { + println!("Error sending event: {}", err); + } + if let Err(err) = + window.emit_all(AUTH_ERROR_EVENT, "Invalid SAML result".to_string()) + { + warn!("Error emitting auth-error event: {:?}", err); + } + } + AuthEvent::Error(AuthError::TokenNotFound) => { + let cancel_timeout_rx = cancel_timeout_rx.clone(); + tokio::spawn(handle_token_not_found(window.clone(), cancel_timeout_rx)); + } + } + } + } +} + +async fn handle_token_not_found(window: Window, cancel_timeout_rx: Arc>>) { + // Tokens not found, show the window in 5 seconds + match cancel_timeout_rx.try_lock() { + Ok(mut cancel_timeout_rx) => { + println!("Scheduling timeout"); + let duration = Duration::from_secs(5); + if let Err(_) = timeout(duration, cancel_timeout_rx.recv()).await { + println!("Show window after timeout"); + show_window(&window); + } else { + println!("Cancel timeout"); + } + } + Err(_) => { + println!("Timeout already scheduled"); + } + } +} + +fn parse_auth_data(main_res: &WebResource, event_tx: mpsc::Sender) { + if let Some(response) = main_res.response() { + if let Some(saml_result) = read_auth_data_from_response(&response) { + // Got SAML result from HTTP headers + println!("SAML result: {:?}", saml_result); + send_auth_data(&event_tx, saml_result); + return; + } + } + + let event_tx = event_tx.clone(); + main_res.data(Cancellable::NONE, move |data| { + if let Ok(data) = data { + let html = String::from_utf8_lossy(&data); + match read_auth_data_from_html(&html) { + Ok(saml_result) => { + // Got SAML result from HTML + println!("SAML result: {:?}", saml_result); + send_auth_data(&event_tx, saml_result); + } + Err(err) => { + println!("Auth error: {:?}", err); + if let Err(err) = event_tx.blocking_send(AuthEvent::Error(err)) { + println!("Error sending event: {}", err) + } + } + } + } + }); +} + +fn read_auth_data_from_response(response: &webkit2gtk::URIResponse) -> Option { response.http_headers().and_then(|mut headers| { let saml_result = AuthData::new( headers.get("saml-username").map(GString::into), @@ -264,14 +346,13 @@ fn read_auth_result_from_response(response: &webkit2gtk::URIResponse) -> Option< }) } -fn read_auth_result_from_html(html: &str) -> Result { +fn read_auth_data_from_html(html: &str) -> Result { let saml_auth_status = parse_xml_tag(html, "saml-auth-status"); - match saml_auth_status { - Some(status) if status == "1" => extract_auth_data(html).ok_or(AuthError::Invalid), - Some(status) if status == "-1" => Err(AuthError::Invalid), - _ => Err(AuthError::NotFound), + Some(status) if status == "1" => extract_auth_data(html).ok_or(AuthError::TokenInvalid), + Some(status) if status == "-1" => Err(AuthError::TokenInvalid), + _ => Err(AuthError::TokenNotFound), } } @@ -295,3 +376,22 @@ fn parse_xml_tag(html: &str, tag: &str) -> Option { .and_then(|captures| captures.get(1)) .map(|m| m.as_str().to_string()) } + +fn send_auth_data(event_tx: &mpsc::Sender, saml_result: AuthData) { + if let Err(err) = event_tx.blocking_send(AuthEvent::Success(saml_result)) { + println!("Error sending event: {}", err) + } +} + +fn show_window(window: &Window) { + match window.is_visible() { + Ok(true) => { + println!("Window is already visible"); + } + _ => { + if let Err(err) = window.show() { + println!("Error showing window: {}", err); + } + } + } +} diff --git a/gpgui/src-tauri/src/main.rs b/gpgui/src-tauri/src/main.rs index aef73e5..e180f0e 100644 --- a/gpgui/src-tauri/src/main.rs +++ b/gpgui/src-tauri/src/main.rs @@ -3,7 +3,7 @@ windows_subsystem = "windows" )] -use auth::{SamlBinding, AuthWindow}; +use auth::{AuthData, AuthRequest, SamlBinding}; use env_logger::Env; use gpcommon::{Client, ServerApiError, VpnStatus}; use serde::Serialize; @@ -37,13 +37,9 @@ async fn saml_login( binding: SamlBinding, request: String, app_handle: AppHandle, -) -> tauri::Result<()> { - let auth_window = AuthWindow::new(app_handle, binding, String::from("PAN GlobalProtect")); - if let Err(err) = auth_window.process(request) { - println!("Error processing auth window: {}", err); - return Err(err); - } - Ok(()) +) -> tauri::Result> { + let ua = "PAN GlobalProtect"; + auth::saml_login(AuthRequest::new(binding, request), ua, &app_handle).await } #[derive(Debug, Clone, Serialize)] diff --git a/gpgui/src/App.tsx b/gpgui/src/App.tsx index de9c082..4255a89 100644 --- a/gpgui/src/App.tsx +++ b/gpgui/src/App.tsx @@ -41,7 +41,10 @@ export default function App() { authService.onAuthError(async () => { const preloginResponse = await portalService.prelogin(portalAddress); // Retry SAML login when auth error occurs - authService.emitAuthRequest(preloginResponse.samlAuthRequest!); + authService.emitAuthRequest({ + samlBinding: preloginResponse.samlAuthMethod!, + samlRequest: preloginResponse.samlAuthRequest!, + }); }); authService.onAuthCancel(() => {}); }, [portalAddress]); @@ -74,7 +77,11 @@ export default function App() { if (portalService.isSamlAuth(response)) { const { samlAuthMethod, samlAuthRequest } = response; - await authService.samlLogin(samlAuthMethod, samlAuthRequest); + const authData = await authService.samlLogin( + samlAuthMethod, + samlAuthRequest + ); + console.log("authData", authData); } else if (portalService.isPasswordAuth(response)) { setPasswordAuthOpen(true); setPasswordAuth({ diff --git a/gpgui/src/services/authService.ts b/gpgui/src/services/authService.ts index 3d8f477..9f107db 100644 --- a/gpgui/src/services/authService.ts +++ b/gpgui/src/services/authService.ts @@ -42,11 +42,17 @@ class AuthService { // binding: "POST" | "REDIRECT" async samlLogin(binding: string, request: string) { - return invokeCommand("saml_login", { binding, request }); + return invokeCommand("saml_login", { binding, request }); } - emitAuthRequest(authRequest: string) { - emit("auth-request", { samlRequest: authRequest }); + emitAuthRequest({ + samlBinding, + samlRequest, + }: { + samlBinding: string; + samlRequest: string; + }) { + emit("auth-request", { samlBinding, samlRequest }); } }