From cec0d22dc883ecaf4701685ffb12cf459951ab2d Mon Sep 17 00:00:00 2001 From: Kevin Yue Date: Mon, 1 Apr 2024 06:28:20 -0400 Subject: [PATCH] Support CAS authentication --- .github/workflows/build.yaml | 53 +++++++++---- .github/workflows/release.yaml | 11 ++- .gitignore | 1 + Makefile | 12 ++- apps/gpclient/src/launch_gui.rs | 2 +- apps/gpgui-helper/src-tauri/src/updater.rs | 18 ++++- 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 ++++++++++-- packaging/binary/Makefile.in | 5 +- 11 files changed, 212 insertions(+), 52 deletions(-) diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index a8c5b15..57d63f4 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -13,7 +13,6 @@ on: - feature/* - release/* tags: - - latest - v*.*.* jobs: # Include arm64 if ref is a tag @@ -50,6 +49,10 @@ jobs: - name: Create tarball run: | cd source/gp + # Generate the SNAPSHOT file for non-tagged commits + if [[ "${{ github.ref }}" != "refs/tags/"* ]]; then + touch SNAPSHOT + fi make tarball - name: Upload tarball uses: actions/upload-artifact@v3 @@ -66,20 +69,39 @@ jobs: strategy: matrix: os: ${{fromJson(needs.setup-matrix.outputs.matrix)}} + package: [deb, rpm, pkg, binary] runs-on: ${{ matrix.os }} steps: - name: Prepare workspace - run: rm -rf build-gp && mkdir build-gp + run: | + rm -rf build-gp-${{ matrix.package }} + mkdir -p build-gp-${{ matrix.package }} - name: Download tarball uses: actions/download-artifact@v3 with: name: artifact-source - path: build-gp + path: build-gp-${{ matrix.package }} - name: Docker Login run: echo ${{ secrets.DOCKER_HUB_TOKEN }} | docker login -u ${{ secrets.DOCKER_HUB_USERNAME }} --password-stdin - - name: Build gp in Docker + - name: Build ${{ matrix.package }} package in Docker run: | - docker run --rm -v $(pwd)/build-gp:/gp yuezk/gpdev:gp-builder + docker run --rm \ + -v $(pwd)/build-gp-${{ matrix.package }}:/${{ matrix.package }} \ + yuezk/gpdev:${{ matrix.package }}-builder + - name: Install ${{ matrix.package }} package in Docker + run: | + docker run --rm \ + -e GPGUI_INSTALLED=0 \ + -v $(pwd)/build-gp-${{ matrix.package }}:/${{ matrix.package }} \ + yuezk/gpdev:${{ matrix.package }}-builder \ + bash install.sh + - name: Upload ${{ matrix.package }} package + uses: actions/upload-artifact@v3 + with: + name: artifact-gp-${{ matrix.os }}-${{ matrix.package }} + if-no-files-found: error + path: | + build-gp-${{ matrix.package }}/artifacts/* build-gpgui: needs: @@ -133,7 +155,7 @@ jobs: gpgui-source/*.bin.tar.xz.sha256 gh-release: - if: startsWith(github.ref, 'refs/tags/') + if: startsWith(github.ref, 'refs/tags/') || github.ref == 'refs/heads/dev' runs-on: ubuntu-latest needs: - build-gp @@ -147,10 +169,15 @@ jobs: with: path: gh-release - name: Create GH release - uses: softprops/action-gh-release@v1 - with: - token: ${{ secrets.GH_PAT }} - prerelease: ${{ contains(github.ref, 'latest') }} - fail_on_unmatched_files: true - files: | - gh-release/artifact-*/* + env: + GH_TOKEN: ${{ secrets.GH_PAT }} + RELEASE_TAG: ${{ github.ref == 'refs/heads/dev' && 'snapshot' || github.ref_name }} + run: | + gh release delete $RELEASE_TAG --yes --cleanup-tag || true + gh release create $RELEASE_TAG \ + --title "$RELEASE_TAG" \ + --notes "Release $RELEASE_TAG" \ + --target ${{ github.ref}} \ + ${{ github.ref == 'refs/heads/dev' && '--prerelease' || '' }} \ + "gh-release/artifact-source/*" \ + "gh-release/artifact-gpgui-*/*" diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 05a172e..2806add 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -109,11 +109,16 @@ jobs: run: echo ${{ secrets.DOCKER_HUB_TOKEN }} | docker login -u ${{ secrets.DOCKER_HUB_USERNAME }} --password-stdin - name: Build ${{ matrix.package }} package in Docker run: | - docker run --rm -v $(pwd)/build-${{ matrix.package }}:/${{ matrix.package }} -e INCLUDE_GUI=1 yuezk/gpdev:${{ matrix.package }}-builder + docker run --rm \ + -v $(pwd)/build-${{ matrix.package }}:/${{ matrix.package }} \ + -e INCLUDE_GUI=1 \ + yuezk/gpdev:${{ matrix.package }}-builder - name: Install ${{ matrix.package }} package in Docker run: | - docker run --rm -v $(pwd)/build-${{ matrix.package }}:/${{ matrix.package }} yuezk/gpdev:${{ matrix.package }}-builder \ + docker run --rm \ + -v $(pwd)/build-${{ matrix.package }}:/${{ matrix.package }} \ + yuezk/gpdev:${{ matrix.package }}-builder \ bash install.sh - name: Upload ${{ matrix.package }} package @@ -140,7 +145,7 @@ jobs: uses: softprops/action-gh-release@v1 with: token: ${{ secrets.GH_PAT }} - prerelease: ${{ contains(github.ref, 'latest') }} + prerelease: ${{ contains(github.ref, 'snapshot') }} fail_on_unmatched_files: true tag_name: ${{ inputs.tag }} files: | diff --git a/.gitignore b/.gitignore index 498e6c8..e2741a9 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ .cargo .build +SNAPSHOT diff --git a/Makefile b/Makefile index 59e5cf3..cd7593d 100644 --- a/Makefile +++ b/Makefile @@ -15,6 +15,13 @@ PUBLISH ?= 0 export DEBEMAIL = k3vinyue@gmail.com export DEBFULLNAME = Kevin Yue +export SNAPSHOT = $(shell test -f SNAPSHOT && echo "true" || echo "false") + +ifeq ($(SNAPSHOT), true) + RELEASE_TAG = snapshot +else + RELEASE_TAG = v$(VERSION) +endif CARGO_BUILD_ARGS = --release @@ -61,7 +68,8 @@ download-gui: if [ $(INCLUDE_GUI) -eq 1 ]; then \ echo "Downloading GlobalProtect GUI..."; \ mkdir -p .build/gpgui; \ - curl -sSL https://github.com/yuezk/GlobalProtect-openconnect/releases/download/v$(VERSION)/gpgui_$(VERSION)_$(shell uname -m).bin.tar.xz -o .build/gpgui/gpgui_$(VERSION)_x$(shell uname -m).bin.tar.xz; \ + curl -sSL https://github.com/yuezk/GlobalProtect-openconnect/releases/download/$(RELEASE_TAG)/gpgui_$(shell uname -m).bin.tar.xz \ + -o .build/gpgui/gpgui_$(shell uname -m).bin.tar.xz; \ tar -xJf .build/gpgui/*.tar.xz -C .build/gpgui; \ else \ echo "Skipping GlobalProtect GUI download (INCLUDE_GUI=0)"; \ @@ -195,7 +203,7 @@ init-rpm: clean-rpm sed -i "s/@VERSION@/$(VERSION)/g" .build/rpm/globalprotect-openconnect.spec sed -i "s/@REVISION@/$(REVISION)/g" .build/rpm/globalprotect-openconnect.spec sed -i "s/@OFFLINE@/$(OFFLINE)/g" .build/rpm/globalprotect-openconnect.spec - sed -i "s/@DATE@/$(shell date "+%a %b %d %Y")/g" .build/rpm/globalprotect-openconnect.spec + sed -i "s/@DATE@/$(shell LC_ALL=en.US date "+%a %b %d %Y")/g" .build/rpm/globalprotect-openconnect.spec sed -i "s/@VERSION@/$(VERSION)/g" .build/rpm/globalprotect-openconnect.changes sed -i "s/@DATE@/$(shell LC_ALL=en.US date -u "+%a %b %e %T %Z %Y")/g" .build/rpm/globalprotect-openconnect.changes 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/apps/gpgui-helper/src-tauri/src/updater.rs b/apps/gpgui-helper/src-tauri/src/updater.rs index ec479a5..096c0de 100644 --- a/apps/gpgui-helper/src-tauri/src/updater.rs +++ b/apps/gpgui-helper/src-tauri/src/updater.rs @@ -9,6 +9,12 @@ use tauri::{Manager, Window}; use crate::downloader::{ChecksumFetcher, FileDownloader}; +#[cfg(not(debug_assertions))] +const SNAPSHOT: &str = match option_env!("SNAPSHOT") { + Some(val) => val, + None => "false" +}; + pub struct ProgressNotifier { win: Window, } @@ -81,9 +87,13 @@ impl GuiUpdater { info!("Update GUI, version: {}", self.version); #[cfg(debug_assertions)] - let release_tag = "latest"; + let release_tag = "snapshot"; #[cfg(not(debug_assertions))] - let release_tag = format!("v{}", self.version); + let release_tag = if SNAPSHOT == "true" { + String::from("snapshot") + } else { + format!("v{}", self.version) + }; #[cfg(target_arch = "x86_64")] let arch = "x86_64"; @@ -91,8 +101,8 @@ impl GuiUpdater { let arch = "aarch64"; let file_url = format!( - "https://github.com/yuezk/GlobalProtect-openconnect/releases/download/{}/gpgui_{}_{}.bin.tar.xz", - release_tag, self.version, arch + "https://github.com/yuezk/GlobalProtect-openconnect/releases/download/{}/gpgui_{}.bin.tar.xz", + release_tag, arch ); let checksum_url = format!("{}.sha256", file_url); 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"); } diff --git a/packaging/binary/Makefile.in b/packaging/binary/Makefile.in index 8f71e68..154ce5b 100644 --- a/packaging/binary/Makefile.in +++ b/packaging/binary/Makefile.in @@ -5,7 +5,10 @@ install: install -Dm755 artifacts/usr/bin/gpservice $(DESTDIR)/usr/bin/gpservice install -Dm755 artifacts/usr/bin/gpauth $(DESTDIR)/usr/bin/gpauth install -Dm755 artifacts/usr/bin/gpgui-helper $(DESTDIR)/usr/bin/gpgui-helper - install -Dm755 artifacts/usr/bin/gpgui $(DESTDIR)/usr/bin/gpgui + + if [ -f artifacts/usr/bin/gpgui ]; then \ + install -Dm755 artifacts/usr/bin/gpgui $(DESTDIR)/usr/bin/gpgui; \ + fi install -Dm644 artifacts/usr/share/applications/gpgui.desktop $(DESTDIR)/usr/share/applications/gpgui.desktop install -Dm644 artifacts/usr/share/icons/hicolor/scalable/apps/gpgui.svg $(DESTDIR)/usr/share/icons/hicolor/scalable/apps/gpgui.svg