more refactor

This commit is contained in:
Kevin Yue
2023-11-13 10:05:06 +08:00
parent 0b4829a610
commit bf2d327687
20 changed files with 965 additions and 64 deletions

22
gpauth/Cargo.toml Normal file
View 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
View 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
View 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
View 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
View 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
}

View File

@@ -0,0 +1,3 @@
pub fn standard_auth(server: &str) {
}