From 338854b4aa42608fa8971972a95ed880d7bbf3fe Mon Sep 17 00:00:00 2001 From: Kevin Yue Date: Mon, 1 Apr 2024 06:28:20 -0400 Subject: [PATCH] Support CAS authentication --- apps/gpclient/src/launch_gui.rs | 2 +- crates/gpapi/src/auth.rs | 20 +++---- crates/gpapi/src/credential.rs | 89 +++++++++++++++++++++++++---- crates/gpapi/src/gp_params.rs | 6 +- crates/gpapi/src/portal/prelogin.rs | 47 +++++++++++++-- 5 files changed, 135 insertions(+), 29 deletions(-) diff --git a/apps/gpclient/src/launch_gui.rs b/apps/gpclient/src/launch_gui.rs index 6a21569..03a5570 100644 --- a/apps/gpclient/src/launch_gui.rs +++ b/apps/gpclient/src/launch_gui.rs @@ -82,7 +82,7 @@ async fn feed_auth_data(auth_data: &str) -> anyhow::Result<()> { reqwest::Client::default() .post(format!("{}/auth-data", service_endpoint)) - .json(&auth_data) + .body(auth_data.to_string()) .send() .await? .error_for_status()?; diff --git a/crates/gpapi/src/auth.rs b/crates/gpapi/src/auth.rs index 2e3fb2a..c64a847 100644 --- a/crates/gpapi/src/auth.rs +++ b/crates/gpapi/src/auth.rs @@ -1,4 +1,4 @@ -use anyhow::bail; +use anyhow::anyhow; use regex::Regex; use serde::{Deserialize, Serialize}; @@ -35,7 +35,7 @@ impl SamlAuthData { } } - pub fn parse_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"); @@ -43,21 +43,17 @@ impl SamlAuthData { let portal_userauthcookie = parse_xml_tag(html, "portal-userauthcookie"); if SamlAuthData::check(&username, &prelogin_cookie, &portal_userauthcookie) { - return Ok(SamlAuthData::new( + Ok(SamlAuthData::new( username.unwrap(), prelogin_cookie, portal_userauthcookie, - )); + )) + } else { + Err(anyhow!("Found invalid auth data in HTML")) } - - 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"); } + Some(status) => Err(anyhow!("Found invalid SAML status {} in HTML", status)), + None => Err(anyhow!("No auth data found in HTML")), } } diff --git a/crates/gpapi/src/credential.rs b/crates/gpapi/src/credential.rs index e1a1be5..a8c64eb 100644 --- a/crates/gpapi/src/credential.rs +++ b/crates/gpapi/src/credential.rs @@ -1,5 +1,6 @@ use std::collections::HashMap; +use log::info; use serde::{Deserialize, Serialize}; use specta::Type; @@ -155,25 +156,50 @@ 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), } 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('"'); + /// 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 = decode_to_string(auth_data)?; - let auth_data = SamlAuthData::parse_html(&auth_data)?; - Self::try_from(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) + } } pub fn username(&self) -> &str { @@ -181,6 +207,7 @@ 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(), } } @@ -189,20 +216,23 @@ impl Credential { let mut params = HashMap::new(); params.insert("user", self.username()); - let (passwd, prelogin_cookie, portal_userauthcookie, portal_prelogonuserauthcookie) = match self { - Credential::Password(cred) => (Some(cred.password()), None, None, None), - Credential::PreloginCookie(cred) => (None, Some(cred.prelogin_cookie()), None, None), + 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::AuthCookie(cred) => ( None, None, Some(cred.user_auth_cookie()), Some(cred.prelogon_user_auth_cookie()), + None, ), + Credential::TokenCredential(cred) => (None, None, None, None, Some(cred.token())), Credential::CachedCredential(cred) => ( cred.password(), None, Some(cred.auth_cookie.user_auth_cookie()), Some(cred.auth_cookie.prelogon_user_auth_cookie()), + None, ), }; @@ -214,6 +244,10 @@ impl Credential { portal_prelogonuserauthcookie.unwrap_or_default(), ); + if let Some(token) = token { + params.insert("token", token); + } + params } } @@ -245,3 +279,38 @@ 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/gp_params.rs b/crates/gpapi/src/gp_params.rs index 03322ac..3606c24 100644 --- a/crates/gpapi/src/gp_params.rs +++ b/crates/gpapi/src/gp_params.rs @@ -42,7 +42,7 @@ impl ClientOs { } } -#[derive(Debug, Serialize, Deserialize, Type, Default)] +#[derive(Debug, Serialize, Deserialize, Type, Default, Clone)] pub struct GpParams { is_gateway: bool, user_agent: String, @@ -83,6 +83,10 @@ impl GpParams { 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() } diff --git a/crates/gpapi/src/portal/prelogin.rs b/crates/gpapi/src/portal/prelogin.rs index 37e50c8..f159df3 100644 --- a/crates/gpapi/src/portal/prelogin.rs +++ b/crates/gpapi/src/portal/prelogin.rs @@ -1,4 +1,4 @@ -use anyhow::bail; +use anyhow::{anyhow, bail}; use log::{info, warn}; use reqwest::{Client, StatusCode}; use roxmltree::Document; @@ -29,6 +29,7 @@ pub struct SamlPrelogin { is_gateway: bool, saml_request: String, support_default_browser: bool, + is_cas: bool, } impl SamlPrelogin { @@ -43,6 +44,14 @@ 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)] @@ -97,6 +106,29 @@ 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); @@ -107,12 +139,16 @@ pub async fn prelogin(portal: &str, gp_params: &GpParams) -> anyhow::Result anyhow::Result anyhow::Result is_gateway, saml_request, support_default_browser, + is_cas: false, }; return Ok(Prelogin::Saml(saml_prelogin)); @@ -196,8 +233,8 @@ fn parse_res_xml(res_xml: String, is_gateway: bool) -> anyhow::Result label_password: label_password.unwrap(), }; - return Ok(Prelogin::Standard(standard_prelogin)); + Ok(Prelogin::Standard(standard_prelogin)) + } else { + Err(anyhow!("Invalid prelogin response")) } - - bail!("Invalid prelogin response"); }