From e9f2dbf9eaa5bbb04f907e9e8d4944db70722c99 Mon Sep 17 00:00:00 2001 From: Kevin Yue Date: Wed, 3 Apr 2024 06:40:40 -0400 Subject: [PATCH] Support CAS authentication --- apps/gpauth/src/auth_window.rs | 77 +++++++++------ crates/gpapi/src/auth.rs | 80 +++++++++++++-- crates/gpapi/src/credential.rs | 115 +++++----------------- crates/gpapi/src/error.rs | 8 ++ crates/gpapi/src/gp_params.rs | 17 ---- crates/gpapi/src/portal/prelogin.rs | 40 +------- crates/gpapi/src/process/auth_launcher.rs | 2 +- 7 files changed, 155 insertions(+), 184 deletions(-) diff --git a/apps/gpauth/src/auth_window.rs b/apps/gpauth/src/auth_window.rs index a97be24..9301c81 100644 --- a/apps/gpauth/src/auth_window.rs +++ b/apps/gpauth/src/auth_window.rs @@ -7,6 +7,7 @@ use std::{ use anyhow::bail; use gpapi::{ auth::SamlAuthData, + error::AuthDataParseError, gp_params::GpParams, portal::{prelogin, Prelogin}, utils::{redact::redact_uri, window::WindowExt}, @@ -359,32 +360,29 @@ fn read_auth_data_from_html(html: &str) -> AuthResult { return Err(AuthDataError::Invalid); } - 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, - )); + let auth_data = match SamlAuthData::from_html(html) { + Ok(auth_data) => Ok(auth_data), + Err(err) => { + if let Some(gpcallback) = extract_gpcallback(html) { + info!("Found gpcallback from html..."); + SamlAuthData::from_gpcallback(gpcallback) + } else { + Err(err) } + } + }; - info!("Found invalid auth data in HTML"); - Err(AuthDataError::Invalid) - } - Some(status) => { - info!("Found invalid SAML status {} in HTML", status); - Err(AuthDataError::Invalid) - } - None => { - info!("No auth data found in HTML"); - Err(AuthDataError::NotFound) - } - } + auth_data.map_err(|err| match err { + AuthDataParseError::NotFound => AuthDataError::NotFound, + AuthDataParseError::Invalid => AuthDataError::Invalid, + }) +} + +fn extract_gpcallback(html: &str) -> Option<&str> { + let re = Regex::new(r#"globalprotectcallback:[^"]+"#).unwrap(); + re.captures(html) + .and_then(|captures| captures.get(0)) + .map(|m| m.as_str()) } fn read_auth_data(main_resource: &WebResource, auth_result_tx: mpsc::UnboundedSender) { @@ -437,13 +435,6 @@ fn read_auth_data(main_resource: &WebResource, auth_result_tx: mpsc::UnboundedSe } } -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()) -} - pub(crate) async fn clear_webview_cookies(window: &Window) -> anyhow::Result<()> { let (tx, rx) = oneshot::channel::>(); @@ -489,3 +480,27 @@ pub(crate) async fn clear_webview_cookies(window: &Window) -> anyhow::Result<()> rx.await?.map_err(|err| anyhow::anyhow!(err)) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn extract_gpcallback_some() { + let html = r#" + + + "#; + + assert_eq!(extract_gpcallback(html), Some("globalprotectcallback:PGh0bWw+PCEtLSA8c")); + } + + #[test] + fn extract_gpcallback_none() { + let html = r#" + + "#; + + assert_eq!(extract_gpcallback(html), None); + } +} diff --git a/crates/gpapi/src/auth.rs b/crates/gpapi/src/auth.rs index c64a847..2561391 100644 --- a/crates/gpapi/src/auth.rs +++ b/crates/gpapi/src/auth.rs @@ -1,13 +1,17 @@ -use anyhow::anyhow; +use log::{info, warn}; use regex::Regex; use serde::{Deserialize, Serialize}; +use crate::{error::AuthDataParseError, utils::base64::decode_to_string}; + #[derive(Debug, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SamlAuthData { + #[serde(alias = "un")] username: String, prelogin_cookie: Option, portal_userauthcookie: Option, + token: Option, } #[derive(Debug, Serialize, Deserialize)] @@ -32,10 +36,11 @@ impl SamlAuthData { username, prelogin_cookie, portal_userauthcookie, + token: None, } } - pub fn from_html(html: &str) -> anyhow::Result { + pub fn from_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"); @@ -49,11 +54,36 @@ impl SamlAuthData { portal_userauthcookie, )) } else { - Err(anyhow!("Found invalid auth data in HTML")) + Err(AuthDataParseError::Invalid) } } - Some(status) => Err(anyhow!("Found invalid SAML status {} in HTML", status)), - None => Err(anyhow!("No auth data found in HTML")), + Some(_) => Err(AuthDataParseError::Invalid), + None => Err(AuthDataParseError::NotFound), + } + } + + pub fn from_gpcallback(data: &str) -> anyhow::Result { + let auth_data = data.trim_start_matches("globalprotectcallback:"); + + if auth_data.starts_with("cas-as") { + info!("Got token auth data: {}", auth_data); + + let token_cred: SamlAuthData = serde_urlencoded::from_str(auth_data).map_err(|e| { + warn!("Failed to parse token auth data: {}", e); + AuthDataParseError::Invalid + })?; + + Ok(token_cred) + } else { + info!("Parsing SAML auth data..."); + + let auth_data = decode_to_string(auth_data).map_err(|e| { + warn!("Failed to decode SAML auth data: {}", e); + AuthDataParseError::Invalid + })?; + let auth_data = Self::from_html(&auth_data)?; + + Ok(auth_data) } } @@ -65,6 +95,10 @@ impl SamlAuthData { self.prelogin_cookie.as_deref() } + pub fn token(&self) -> Option<&str> { + self.token.as_deref() + } + pub fn check( username: &Option, prelogin_cookie: &Option, @@ -74,7 +108,16 @@ impl SamlAuthData { 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) + let is_valid = username_valid && (prelogin_cookie_valid || portal_userauthcookie_valid); + + if !is_valid { + warn!( + "Invalid SAML auth data: username: {:?}, prelogin-cookie: {:?}, portal-userauthcookie: {:?}", + username, prelogin_cookie, portal_userauthcookie + ); + } + + is_valid } } @@ -84,3 +127,28 @@ pub fn parse_xml_tag(html: &str, tag: &str) -> Option { .and_then(|captures| captures.get(1)) .map(|m| m.as_str().to_string()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn auth_data_from_gpcallback_cas() { + let auth_data = "globalprotectcallback:cas-as=1&un=xyz@email.com&token=very_long_string"; + + let auth_data = SamlAuthData::from_gpcallback(auth_data).unwrap(); + + assert_eq!(auth_data.username(), "xyz@email.com"); + assert_eq!(auth_data.token(), Some("very_long_string")); + } + + #[test] + fn auth_data_from_gpcallback_non_cas() { + let auth_data = "PGh0bWw+PCEtLSA8c2FtbC1hdXRoLXN0YXR1cz4xPC9zYW1sLWF1dGgtc3RhdHVzPjxwcmVsb2dpbi1jb29raWU+cHJlbG9naW4tY29va2llPC9wcmVsb2dpbi1jb29raWU+PHNhbWwtdXNlcm5hbWU+eHl6QGVtYWlsLmNvbTwvc2FtbC11c2VybmFtZT48c2FtbC1zbG8+bm88L3NhbWwtc2xvPjxzYW1sLVNlc3Npb25Ob3RPbk9yQWZ0ZXI+PC9zYW1sLVNlc3Npb25Ob3RPbk9yQWZ0ZXI+IC0tPjwvaHRtbD4="; + + let auth_data = SamlAuthData::from_gpcallback(auth_data).unwrap(); + + assert_eq!(auth_data.username(), "xyz@email.com"); + assert_eq!(auth_data.prelogin_cookie(), Some("prelogin-cookie")); + } +} diff --git a/crates/gpapi/src/credential.rs b/crates/gpapi/src/credential.rs index a8c64eb..44f3dad 100644 --- a/crates/gpapi/src/credential.rs +++ b/crates/gpapi/src/credential.rs @@ -1,10 +1,9 @@ use std::collections::HashMap; -use log::info; use serde::{Deserialize, Serialize}; use specta::Type; -use crate::{auth::SamlAuthData, utils::base64::decode_to_string}; +use crate::auth::SamlAuthData; #[derive(Debug, Serialize, Deserialize, Type, Clone)] #[serde(rename_all = "camelCase")] @@ -40,14 +39,16 @@ impl From<&CachedCredential> for PasswordCredential { #[serde(rename_all = "camelCase")] pub struct PreloginCookieCredential { username: String, - prelogin_cookie: String, + prelogin_cookie: Option, + token: Option, } impl PreloginCookieCredential { - pub fn new(username: &str, prelogin_cookie: &str) -> Self { + pub fn new(username: &str, prelogin_cookie: Option<&str>, token: Option<&str>) -> Self { Self { username: username.to_string(), - prelogin_cookie: prelogin_cookie.to_string(), + prelogin_cookie: prelogin_cookie.map(|s| s.to_string()), + token: token.map(|s| s.to_string()), } } @@ -55,22 +56,22 @@ impl PreloginCookieCredential { &self.username } - pub fn prelogin_cookie(&self) -> &str { - &self.prelogin_cookie + pub fn prelogin_cookie(&self) -> Option<&str> { + self.prelogin_cookie.as_deref() + } + + pub fn token(&self) -> Option<&str> { + self.token.as_deref() } } -impl TryFrom for PreloginCookieCredential { - type Error = anyhow::Error; - - fn try_from(value: SamlAuthData) -> Result { +impl From for PreloginCookieCredential { + fn from(value: SamlAuthData) -> Self { let username = value.username().to_string(); - let prelogin_cookie = value - .prelogin_cookie() - .ok_or_else(|| anyhow::anyhow!("Missing prelogin cookie"))? - .to_string(); + let prelogin_cookie = value.prelogin_cookie(); + let token = value.token(); - Ok(Self::new(&username, &prelogin_cookie)) + Self::new(&username, prelogin_cookie, token) } } @@ -155,31 +156,12 @@ impl From for CachedCredential { ) } } - -#[derive(Debug, Serialize, Deserialize, Type, Clone)] -pub struct TokenCredential { - #[serde(alias = "un")] - username: String, - token: String, -} - -impl TokenCredential { - pub fn username(&self) -> &str { - &self.username - } - - pub fn token(&self) -> &str { - &self.token - } -} - #[derive(Debug, Serialize, Deserialize, Type, Clone)] #[serde(tag = "type", rename_all = "camelCase")] pub enum Credential { Password(PasswordCredential), PreloginCookie(PreloginCookieCredential), AuthCookie(AuthCookieCredential), - TokenCredential(TokenCredential), CachedCredential(CachedCredential), } @@ -187,19 +169,9 @@ impl Credential { /// Create a credential from a globalprotectcallback:, /// or globalprotectcallback:cas-as=1&un=user@xyz.com&token=very_long_string pub fn from_gpcallback(auth_data: &str) -> anyhow::Result { - let auth_data = auth_data.trim_start_matches("globalprotectcallback:"); + let auth_data = SamlAuthData::from_gpcallback(auth_data)?; - if auth_data.starts_with("cas-as") { - info!("Got token auth data: {}", auth_data); - let token_cred: TokenCredential = serde_urlencoded::from_str(auth_data)?; - Ok(Self::TokenCredential(token_cred)) - } else { - info!("Parsing SAML auth data..."); - let auth_data = decode_to_string(auth_data)?; - let auth_data = SamlAuthData::from_html(&auth_data)?; - - Self::try_from(auth_data) - } + Ok(Self::from(auth_data)) } pub fn username(&self) -> &str { @@ -207,7 +179,6 @@ impl Credential { Credential::Password(cred) => cred.username(), Credential::PreloginCookie(cred) => cred.username(), Credential::AuthCookie(cred) => cred.username(), - Credential::TokenCredential(cred) => cred.username(), Credential::CachedCredential(cred) => cred.username(), } } @@ -218,7 +189,7 @@ impl Credential { let (passwd, prelogin_cookie, portal_userauthcookie, portal_prelogonuserauthcookie, token) = match self { Credential::Password(cred) => (Some(cred.password()), None, None, None, None), - Credential::PreloginCookie(cred) => (None, Some(cred.prelogin_cookie()), None, None, None), + Credential::PreloginCookie(cred) => (None, cred.prelogin_cookie(), None, None, cred.token()), Credential::AuthCookie(cred) => ( None, None, @@ -226,7 +197,6 @@ impl Credential { Some(cred.prelogon_user_auth_cookie()), None, ), - Credential::TokenCredential(cred) => (None, None, None, None, Some(cred.token())), Credential::CachedCredential(cred) => ( cred.password(), None, @@ -252,13 +222,11 @@ impl Credential { } } -impl TryFrom for Credential { - type Error = anyhow::Error; +impl From for Credential { + fn from(value: SamlAuthData) -> Self { + let cred = PreloginCookieCredential::from(value); - fn try_from(value: SamlAuthData) -> Result { - let prelogin_cookie = PreloginCookieCredential::try_from(value)?; - - Ok(Self::PreloginCookie(prelogin_cookie)) + Self::PreloginCookie(cred) } } @@ -279,38 +247,3 @@ impl From<&CachedCredential> for Credential { Self::CachedCredential(value.clone()) } } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn cred_from_gpcallback_cas() { - let auth_data = "globalprotectcallback:cas-as=1&un=xyz@email.com&token=very_long_string"; - - let cred = Credential::from_gpcallback(auth_data).unwrap(); - - match cred { - Credential::TokenCredential(token_cred) => { - assert_eq!(token_cred.username(), "xyz@email.com"); - assert_eq!(token_cred.token(), "very_long_string"); - } - _ => panic!("Expected TokenCredential"), - } - } - - #[test] - fn cred_from_gpcallback_non_cas() { - let auth_data = "PGh0bWw+PCEtLSA8c2FtbC1hdXRoLXN0YXR1cz4xPC9zYW1sLWF1dGgtc3RhdHVzPjxwcmVsb2dpbi1jb29raWU+cHJlbG9naW4tY29va2llPC9wcmVsb2dpbi1jb29raWU+PHNhbWwtdXNlcm5hbWU+eHl6QGVtYWlsLmNvbTwvc2FtbC11c2VybmFtZT48c2FtbC1zbG8+bm88L3NhbWwtc2xvPjxzYW1sLVNlc3Npb25Ob3RPbk9yQWZ0ZXI+PC9zYW1sLVNlc3Npb25Ob3RPbk9yQWZ0ZXI+IC0tPjwvaHRtbD4="; - - let cred = Credential::from_gpcallback(auth_data).unwrap(); - - match cred { - Credential::PreloginCookie(cred) => { - assert_eq!(cred.username(), "xyz@email.com"); - assert_eq!(cred.prelogin_cookie(), "prelogin-cookie"); - } - _ => panic!("Expected PreloginCookieCredential") - } - } -} diff --git a/crates/gpapi/src/error.rs b/crates/gpapi/src/error.rs index 712e679..b1f578c 100644 --- a/crates/gpapi/src/error.rs +++ b/crates/gpapi/src/error.rs @@ -9,3 +9,11 @@ pub enum PortalError { #[error("Network error: {0}")] NetworkError(String), } + +#[derive(Error, Debug)] +pub enum AuthDataParseError { + #[error("No auth data found")] + NotFound, + #[error("Invalid auth data")] + Invalid, +} diff --git a/crates/gpapi/src/gp_params.rs b/crates/gpapi/src/gp_params.rs index 3606c24..0694c14 100644 --- a/crates/gpapi/src/gp_params.rs +++ b/crates/gpapi/src/gp_params.rs @@ -51,7 +51,6 @@ pub struct GpParams { client_version: Option, computer: String, ignore_tls_errors: bool, - prefer_default_browser: bool, } impl GpParams { @@ -79,14 +78,6 @@ impl GpParams { self.ignore_tls_errors } - pub fn prefer_default_browser(&self) -> bool { - self.prefer_default_browser - } - - pub fn set_prefer_default_browser(&mut self, prefer_default_browser: bool) { - self.prefer_default_browser = prefer_default_browser; - } - pub fn client_os(&self) -> &str { self.client_os.as_str() } @@ -135,7 +126,6 @@ pub struct GpParamsBuilder { client_version: Option, computer: String, ignore_tls_errors: bool, - prefer_default_browser: bool, } impl GpParamsBuilder { @@ -148,7 +138,6 @@ impl GpParamsBuilder { client_version: Default::default(), computer: whoami::hostname(), ignore_tls_errors: false, - prefer_default_browser: false, } } @@ -187,11 +176,6 @@ 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 { is_gateway: self.is_gateway, @@ -201,7 +185,6 @@ 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 f159df3..c2c8ac8 100644 --- a/crates/gpapi/src/portal/prelogin.rs +++ b/crates/gpapi/src/portal/prelogin.rs @@ -29,7 +29,6 @@ pub struct SamlPrelogin { is_gateway: bool, saml_request: String, support_default_browser: bool, - is_cas: bool, } impl SamlPrelogin { @@ -44,14 +43,6 @@ impl SamlPrelogin { pub fn support_default_browser(&self) -> bool { self.support_default_browser } - - pub fn is_cas(&self) -> bool { - self.is_cas - } - - fn set_is_cas(&mut self, is_cas: bool) { - self.is_cas = is_cas; - } } #[derive(Debug, Serialize, Type, Clone)] @@ -106,29 +97,6 @@ impl Prelogin { } pub async fn prelogin(portal: &str, gp_params: &GpParams) -> anyhow::Result { - match prelogin_impl(portal, gp_params).await { - Ok(prelogin) => Ok(prelogin), - Err(e) => { - if e.to_string().contains("CAS is not supported by the client") { - info!("CAS authentication detected, retrying with default browser"); - let mut gp_params = gp_params.clone(); - gp_params.set_prefer_default_browser(true); - - let mut prelogin = prelogin_impl(portal, &gp_params).await?; - // Mark the prelogin as CAS - if let Prelogin::Saml(saml) = &mut prelogin { - saml.set_is_cas(true); - } - - Ok(prelogin) - } else { - Err(e) - } - } - } -} - -pub async fn prelogin_impl(portal: &str, gp_params: &GpParams) -> anyhow::Result { let user_agent = gp_params.user_agent(); info!("Prelogin with user_agent: {}", user_agent); @@ -139,11 +107,8 @@ pub async fn prelogin_impl(portal: &str, gp_params: &GpParams) -> anyhow::Result let mut params = gp_params.to_params(); params.insert("tmp", "tmp"); - // CAS support requires external browser - if gp_params.prefer_default_browser() { - params.insert("default-browser", "1"); - params.insert("cas-support", "yes"); - } + params.insert("default-browser", "1"); + params.insert("cas-support", "yes"); params.retain(|k, _| REQUIRED_PARAMS.iter().any(|required_param| required_param == k)); @@ -213,7 +178,6 @@ fn parse_res_xml(res_xml: String, is_gateway: bool) -> anyhow::Result is_gateway, saml_request, support_default_browser, - is_cas: false, }; return Ok(Prelogin::Saml(saml_prelogin)); diff --git a/crates/gpapi/src/process/auth_launcher.rs b/crates/gpapi/src/process/auth_launcher.rs index c8867eb..fc012fe 100644 --- a/crates/gpapi/src/process/auth_launcher.rs +++ b/crates/gpapi/src/process/auth_launcher.rs @@ -135,7 +135,7 @@ impl<'a> SamlAuthLauncher<'a> { }; match auth_result { - SamlAuthResult::Success(auth_data) => Credential::try_from(auth_data), + SamlAuthResult::Success(auth_data) => Ok(Credential::from(auth_data)), SamlAuthResult::Failure(msg) => bail!(msg), } }