mirror of
https://github.com/yuezk/GlobalProtect-openconnect.git
synced 2025-05-20 07:26:58 -04:00
more refactor
This commit is contained in:
22
gpauth/Cargo.toml
Normal file
22
gpauth/Cargo.toml
Normal file
@@ -0,0 +1,22 @@
|
||||
[package]
|
||||
name = "gpauth"
|
||||
version.workspace = true
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
gpcommon = { path = "../gpcommon" }
|
||||
anyhow.workspace = true
|
||||
clap.workspace = true
|
||||
directories.workspace = true
|
||||
fern.workspace = true
|
||||
humantime.workspace = true
|
||||
log.workspace = true
|
||||
regex.workspace = true
|
||||
reqwest.workspace = true
|
||||
serde_json.workspace = true
|
||||
serde.workspace = true
|
||||
tokio.workspace = true
|
||||
webkit2gtk = "0.18.2"
|
||||
wry = "0.24"
|
||||
|
||||
[dev-dependencies]
|
395
gpauth/src/auth_window.rs
Normal file
395
gpauth/src/auth_window.rs
Normal file
@@ -0,0 +1,395 @@
|
||||
use directories::ProjectDirs;
|
||||
use gpcommon::portal::{Portal, Prelogin, SamlPrelogin};
|
||||
use log::{info, warn};
|
||||
use regex::Regex;
|
||||
use serde::Serialize;
|
||||
use std::{borrow::Cow, cell::RefCell, path::PathBuf, rc::Rc};
|
||||
use tokio::sync::mpsc;
|
||||
use webkit2gtk::{
|
||||
gio::Cancellable, glib::GString, LoadEvent, URIResponse, URIResponseExt, WebResource,
|
||||
WebResourceExt, WebViewExt,
|
||||
};
|
||||
use wry::{
|
||||
application::{
|
||||
event::{Event, StartCause, WindowEvent},
|
||||
event_loop::{ControlFlow, EventLoop, EventLoopProxy},
|
||||
platform::run_return::EventLoopExtRunReturn,
|
||||
window::WindowBuilder,
|
||||
},
|
||||
webview::{WebContext, WebView, WebViewBuilder, WebviewExtUnix},
|
||||
};
|
||||
|
||||
enum UserEvent {
|
||||
AuthFailed,
|
||||
AuthSuccess(SamlAuthData),
|
||||
AuthRequest(String),
|
||||
Exit,
|
||||
}
|
||||
|
||||
enum AuthEvent {
|
||||
FetchAuthRequest,
|
||||
Exit,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct SamlAuthData {
|
||||
username: String,
|
||||
prelogin_cookie: Option<String>,
|
||||
portal_userauthcookie: Option<String>,
|
||||
}
|
||||
|
||||
impl SamlAuthData {
|
||||
fn check(
|
||||
username: &Option<String>,
|
||||
prelogin_cookie: &Option<String>,
|
||||
portal_userauthcookie: &Option<String>,
|
||||
) -> bool {
|
||||
let username_valid = username
|
||||
.as_ref()
|
||||
.is_some_and(|username| !username.is_empty());
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
enum AuthResult {
|
||||
NotFound,
|
||||
Invalid,
|
||||
Success(SamlAuthData),
|
||||
}
|
||||
|
||||
impl AuthResult {
|
||||
fn new(
|
||||
username: Option<String>,
|
||||
prelogin_cookie: Option<String>,
|
||||
portal_userauthcookie: Option<String>,
|
||||
) -> Self {
|
||||
if SamlAuthData::check(&username, &prelogin_cookie, &portal_userauthcookie) {
|
||||
AuthResult::Success(SamlAuthData {
|
||||
username: username.unwrap(),
|
||||
prelogin_cookie,
|
||||
portal_userauthcookie,
|
||||
})
|
||||
} else {
|
||||
AuthResult::Invalid
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct AuthWindow {
|
||||
portal: Portal,
|
||||
event_loop: EventLoop<UserEvent>,
|
||||
webview: WebView,
|
||||
}
|
||||
|
||||
impl AuthWindow {
|
||||
pub fn new(portal: Portal, user_agent: &str) -> anyhow::Result<Self> {
|
||||
let event_loop = EventLoop::with_user_event();
|
||||
let window = WindowBuilder::new()
|
||||
.with_title("GlobalProtect Login")
|
||||
.build(&event_loop)?;
|
||||
let mut web_context = WebContext::new(data_dir());
|
||||
let webview = WebViewBuilder::new(window)?
|
||||
.with_user_agent(user_agent)
|
||||
.with_web_context(&mut web_context)
|
||||
.build()?;
|
||||
|
||||
Ok(Self {
|
||||
portal,
|
||||
event_loop,
|
||||
webview,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn run(&mut self, saml_request: Option<&str>) -> anyhow::Result<SamlAuthData> {
|
||||
let saml_request = match saml_request {
|
||||
Some(saml_request) => Cow::Borrowed(saml_request),
|
||||
None => Cow::Owned(Self::saml_request(&self.portal).await?),
|
||||
};
|
||||
|
||||
self.setup_webview(&saml_request);
|
||||
|
||||
let (sender, receiver) = mpsc::unbounded_channel::<AuthEvent>();
|
||||
self.setup_channel(receiver);
|
||||
|
||||
let auth_data_ret: Rc<RefCell<Option<SamlAuthData>>> = Default::default();
|
||||
let wv = self.webview.webview();
|
||||
self.event_loop.run_return(|event, _, control_flow| {
|
||||
*control_flow = ControlFlow::Wait;
|
||||
|
||||
match event {
|
||||
Event::NewEvents(StartCause::Init) => info!("Auth window is ready"),
|
||||
Event::WindowEvent {
|
||||
event: WindowEvent::CloseRequested,
|
||||
..
|
||||
} => {
|
||||
info!("Close requested, exiting the auth window...");
|
||||
Self::send_auth_event(&sender, AuthEvent::Exit);
|
||||
*control_flow = ControlFlow::Exit;
|
||||
}
|
||||
Event::UserEvent(UserEvent::AuthRequest(saml_request)) => {
|
||||
Self::load_saml_request(wv.clone(), &saml_request);
|
||||
}
|
||||
Event::UserEvent(UserEvent::AuthFailed) => {
|
||||
info!("Auth failed, retrying...");
|
||||
wv.run_javascript(r#"
|
||||
var loading = document.createElement("div");
|
||||
loading.innerHTML = '<div style="position: absolute; width: 100%; text-align: center; font-size: 20px; font-weight: bold; top: 50%; left: 50%; transform: translate(-50%, -50%);">Got invalid token, retrying...</div>';
|
||||
loading.style = "position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(255, 255, 255, 0.85); z-index: 99999;";
|
||||
document.body.appendChild(loading);
|
||||
"#,
|
||||
Cancellable::NONE,
|
||||
|_| info!("Injected loading element successfully"),
|
||||
);
|
||||
Self::send_auth_event(&sender, AuthEvent::FetchAuthRequest);
|
||||
}
|
||||
Event::UserEvent(UserEvent::AuthSuccess(auth_data)) => {
|
||||
info!("Auth success, exit the auth window...");
|
||||
*auth_data_ret.borrow_mut() = Some(auth_data);
|
||||
Self::send_auth_event(&sender, AuthEvent::Exit);
|
||||
*control_flow = ControlFlow::Exit;
|
||||
}
|
||||
Event::UserEvent(UserEvent::Exit) => {
|
||||
info!("Exit event received, exiting the auth window...");
|
||||
Self::send_auth_event(&sender, AuthEvent::Exit);
|
||||
*control_flow = ControlFlow::Exit;
|
||||
}
|
||||
_ => (),
|
||||
}
|
||||
});
|
||||
|
||||
auth_data_ret
|
||||
.take()
|
||||
.ok_or_else(|| anyhow::anyhow!("Auth window exited without auth data"))
|
||||
}
|
||||
|
||||
fn send_user_event(event_proxy: &EventLoopProxy<UserEvent>, event: UserEvent) {
|
||||
if let Err(err) = event_proxy.send_event(event) {
|
||||
warn!("Failed to send user event: {}", err);
|
||||
}
|
||||
}
|
||||
|
||||
fn send_auth_event(sender: &mpsc::UnboundedSender<AuthEvent>, event: AuthEvent) {
|
||||
if let Err(err) = sender.send(event) {
|
||||
warn!("Failed to send auth event: {}", err);
|
||||
}
|
||||
}
|
||||
|
||||
async fn saml_request(portal: &Portal) -> anyhow::Result<String> {
|
||||
let prelogin = portal.prelogin().await?;
|
||||
|
||||
if let Prelogin::Saml(SamlPrelogin {
|
||||
method, request, ..
|
||||
}) = prelogin
|
||||
{
|
||||
info!("Received SAML prelogin response, method: {}", method);
|
||||
Ok(request)
|
||||
} else {
|
||||
Err(anyhow::anyhow!("Received non-SAML prelogin response"))
|
||||
}
|
||||
}
|
||||
|
||||
fn load_saml_request(wv: Rc<webkit2gtk::WebView>, saml_request: &str) {
|
||||
if saml_request.starts_with("http") {
|
||||
info!("Load the SAML request as URI...");
|
||||
wv.load_uri(saml_request);
|
||||
} else {
|
||||
info!("Load the SAML request as HTML...");
|
||||
wv.load_html(saml_request, None);
|
||||
}
|
||||
}
|
||||
|
||||
fn setup_webview(&self, saml_request: &str) {
|
||||
let wv = self.webview.webview();
|
||||
let event_proxy = self.event_loop.create_proxy();
|
||||
|
||||
Self::load_saml_request(wv.clone(), saml_request);
|
||||
|
||||
wv.connect_load_changed(move |wv, event| {
|
||||
if event != LoadEvent::Finished {
|
||||
return;
|
||||
}
|
||||
|
||||
let uri = wv.uri().unwrap_or("".into());
|
||||
if uri.is_empty() {
|
||||
warn!("Loaded an empty URI, auth failed");
|
||||
Self::send_user_event(&event_proxy, UserEvent::AuthFailed);
|
||||
return;
|
||||
}
|
||||
|
||||
if uri == "about:blank" {
|
||||
info!("Loaded about:blank, skipping");
|
||||
return;
|
||||
}
|
||||
|
||||
info!("Loaded URI: {}", uri);
|
||||
if let Some(main_resource) = wv.main_resource() {
|
||||
Self::read_auth_data(&main_resource, event_proxy.clone());
|
||||
} else {
|
||||
warn!("No main resource found for {}, skipping", uri);
|
||||
}
|
||||
});
|
||||
|
||||
let event_proxy = self.event_loop.create_proxy();
|
||||
wv.connect_load_failed(move |_wv, _event, uri, err| {
|
||||
warn!("Load failed: {:?}, {:?}", uri, err);
|
||||
Self::send_user_event(&event_proxy, UserEvent::AuthFailed);
|
||||
false
|
||||
});
|
||||
}
|
||||
|
||||
fn setup_channel(&self, mut receiver: mpsc::UnboundedReceiver<AuthEvent>) {
|
||||
let portal = self.portal.clone();
|
||||
let event_proxy = self.event_loop.create_proxy();
|
||||
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
match receiver.recv().await {
|
||||
Some(AuthEvent::FetchAuthRequest) => {
|
||||
info!("Fetching auth request...");
|
||||
match Self::saml_request(&portal).await {
|
||||
Ok(request) => {
|
||||
Self::send_user_event(&event_proxy, UserEvent::AuthRequest(request))
|
||||
}
|
||||
Err(err) => {
|
||||
warn!("Failed to fetch prelogin response: {}", err);
|
||||
Self::send_user_event(&event_proxy, UserEvent::Exit);
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(AuthEvent::Exit) => {
|
||||
info!("Auth window exited, exiting the receiver...");
|
||||
break;
|
||||
}
|
||||
None => {
|
||||
info!("Auth event channel closed, exiting the receiver...");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn read_auth_data(main_resource: &WebResource, event_proxy: EventLoopProxy<UserEvent>) {
|
||||
if main_resource.response().is_none() {
|
||||
info!("No response found for main resource");
|
||||
return;
|
||||
}
|
||||
|
||||
let response = main_resource.response().unwrap();
|
||||
info!("Trying to read auth data from response headers...");
|
||||
match read_auth_data_from_headers(&response) {
|
||||
AuthResult::Success(auth_data) => {
|
||||
Self::send_user_event(&event_proxy, UserEvent::AuthSuccess(auth_data));
|
||||
}
|
||||
AuthResult::NotFound => {
|
||||
info!("No auth data found in response headers, trying to read from HTML...");
|
||||
read_auth_data_from_body(main_resource, move |auth_result| match auth_result {
|
||||
AuthResult::Success(auth_data) => {
|
||||
Self::send_user_event(&event_proxy, UserEvent::AuthSuccess(auth_data));
|
||||
}
|
||||
AuthResult::Invalid => {
|
||||
Self::send_user_event(&event_proxy, UserEvent::AuthFailed);
|
||||
}
|
||||
AuthResult::NotFound => {
|
||||
info!("No auth data found in HTML, it may not be the '/SAML20/SP/ACS' endpoint");
|
||||
}
|
||||
});
|
||||
}
|
||||
AuthResult::Invalid => {
|
||||
info!("Found invalid auth data in response headers, trying to read from HTML...");
|
||||
read_auth_data_from_body(main_resource, move |auth_result| {
|
||||
if let AuthResult::Success(auth_data) = auth_result {
|
||||
Self::send_user_event(&event_proxy, UserEvent::AuthSuccess(auth_data));
|
||||
} else {
|
||||
Self::send_user_event(&event_proxy, UserEvent::AuthFailed);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn data_dir() -> Option<PathBuf> {
|
||||
ProjectDirs::from("com.yuezk", "GlobalProtect-openconnect", "gpauth")
|
||||
.map(|dirs| dirs.data_dir().into())
|
||||
}
|
||||
|
||||
fn read_auth_data_from_headers(response: &URIResponse) -> AuthResult {
|
||||
response
|
||||
.http_headers()
|
||||
.map_or(AuthResult::NotFound, |mut headers| {
|
||||
match headers.get("saml-auth-status") {
|
||||
Some(saml_status) if saml_status == "1" => {
|
||||
info!("Found valid SAML status in header");
|
||||
|
||||
let username = headers.get("saml-username").map(GString::into);
|
||||
let prelogin_cookie = headers.get("prelogin-cookie").map(GString::into);
|
||||
let portal_userauthcookie =
|
||||
headers.get("portal-userauthcookie").map(GString::into);
|
||||
|
||||
AuthResult::new(username, prelogin_cookie, portal_userauthcookie)
|
||||
}
|
||||
Some(saml_status) => {
|
||||
info!("Found invalid SAML status in header: {}", saml_status);
|
||||
AuthResult::Invalid
|
||||
}
|
||||
None => {
|
||||
info!("No auth data found in response headers");
|
||||
AuthResult::NotFound
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn read_auth_data_from_body<F>(main_resource: &WebResource, callback: F)
|
||||
where
|
||||
F: FnOnce(AuthResult) + Send + 'static,
|
||||
{
|
||||
main_resource.data(Cancellable::NONE, |data| {
|
||||
let auth_result = data.map_or(AuthResult::NotFound, |data| {
|
||||
let html = String::from_utf8_lossy(&data).to_string();
|
||||
read_auth_data_from_html(&html)
|
||||
});
|
||||
|
||||
callback(auth_result);
|
||||
});
|
||||
}
|
||||
|
||||
fn read_auth_data_from_html(html: &str) -> AuthResult {
|
||||
if html.contains("Temporarily Unavailable") {
|
||||
info!("Found 'Temporarily Unavailable' in HTML, auth failed");
|
||||
return AuthResult::Invalid;
|
||||
}
|
||||
|
||||
match parse_xml_tag(html, "saml-auth-status") {
|
||||
Some(saml_status) if saml_status == "1" => {
|
||||
info!("Found valid status in HTML");
|
||||
|
||||
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");
|
||||
|
||||
AuthResult::new(username, prelogin_cookie, portal_userauthcookie)
|
||||
}
|
||||
Some(saml_status) => {
|
||||
info!("Found invalid SAML status in HTML: {}", saml_status);
|
||||
AuthResult::Invalid
|
||||
}
|
||||
None => {
|
||||
info!("No auth data found in HTML");
|
||||
AuthResult::NotFound
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_xml_tag(html: &str, tag: &str) -> Option<String> {
|
||||
let re = Regex::new(&format!("<{}>(.*)</{}>", tag, tag)).unwrap();
|
||||
re.captures(html)
|
||||
.and_then(|captures| captures.get(1))
|
||||
.map(|m| m.as_str().to_string())
|
||||
}
|
6
gpauth/src/lib.rs
Normal file
6
gpauth/src/lib.rs
Normal file
@@ -0,0 +1,6 @@
|
||||
mod auth_window;
|
||||
mod saml_auth;
|
||||
mod standard_auth;
|
||||
|
||||
pub use saml_auth::saml_auth;
|
||||
pub use standard_auth::standard_auth;
|
48
gpauth/src/main.rs
Normal file
48
gpauth/src/main.rs
Normal file
@@ -0,0 +1,48 @@
|
||||
use clap::{arg, Command};
|
||||
use gpauth::saml_auth;
|
||||
use gpcommon::portal::{Portal, Prelogin, SamlPrelogin};
|
||||
use serde_json::json;
|
||||
|
||||
fn cli() -> Command {
|
||||
Command::new("gpauth")
|
||||
.about("GlobalProtect-openconnect authentication helper")
|
||||
.arg_required_else_help(true)
|
||||
.arg(arg!(<SERVER> "The GlobalProtect server"))
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
fern::Dispatch::new()
|
||||
.format(|out, message, record| {
|
||||
out.finish(format_args!(
|
||||
"[{} {} {}] {}",
|
||||
humantime::format_rfc3339_millis(std::time::SystemTime::now()),
|
||||
record.level(),
|
||||
record.target(),
|
||||
message
|
||||
))
|
||||
})
|
||||
.level(log::LevelFilter::Info)
|
||||
.chain(std::io::stderr())
|
||||
.apply()?;
|
||||
|
||||
let matches = cli().get_matches();
|
||||
let server = matches.get_one::<String>("SERVER").expect("Missing server");
|
||||
let address = if server.starts_with("https://") || server.starts_with("http://") {
|
||||
server.to_string()
|
||||
} else {
|
||||
format!("https://{}", server)
|
||||
};
|
||||
|
||||
let portal = Portal::new(&address);
|
||||
let saml_request = match portal.prelogin().await? {
|
||||
Prelogin::Saml(SamlPrelogin { request, .. }) => request,
|
||||
_ => anyhow::bail!("Prelogin response is not SAML"),
|
||||
};
|
||||
let auth_data = saml_auth(&portal, Some(&saml_request)).await?;
|
||||
|
||||
// Output the auth data as JSON, so that the client can parse it
|
||||
println!("{}", json!(auth_data));
|
||||
|
||||
Ok(())
|
||||
}
|
9
gpauth/src/saml_auth.rs
Normal file
9
gpauth/src/saml_auth.rs
Normal file
@@ -0,0 +1,9 @@
|
||||
use crate::auth_window::{SamlAuthData, AuthWindow};
|
||||
use gpcommon::portal::Portal;
|
||||
|
||||
pub async fn saml_auth(portal: &Portal, saml_request: Option<&str>) -> anyhow::Result<SamlAuthData> {
|
||||
let user_agent = "PAN GlobalProtect";
|
||||
let mut auth_window = AuthWindow::new(portal.clone(), user_agent)?;
|
||||
|
||||
auth_window.run(saml_request).await
|
||||
}
|
3
gpauth/src/standard_auth.rs
Normal file
3
gpauth/src/standard_auth.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
pub fn standard_auth(server: &str) {
|
||||
|
||||
}
|
Reference in New Issue
Block a user