From 26d54f91d50cddcce8f8eed36fcc7ae9953e65da Mon Sep 17 00:00:00 2001 From: James Magahern Date: Sat, 3 May 2025 01:06:50 -0700 Subject: [PATCH] implements authentication/token retrieval/keyring --- Cargo.lock | 128 +++++++++++++++++- kordophone/src/api/http_client.rs | 56 +++++--- kordophone/src/model/jwt.rs | 46 +++++-- kordophoned/Cargo.toml | 1 + .../net.buzzert.kordophonecd.Server.xml | 6 - kordophoned/src/daemon/auth_store.rs | 45 +++--- kordophoned/src/daemon/mod.rs | 1 + kordophoned/src/daemon/settings.rs | 7 - kordophoned/src/dbus/server_impl.rs | 20 --- kpcli/src/daemon/mod.rs | 15 +- kpcli/src/db/mod.rs | 8 +- 11 files changed, 234 insertions(+), 99 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 026e141..0172a63 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -430,6 +430,19 @@ dependencies = [ "dbus", ] +[[package]] +name = "dbus-secret-service" +version = "4.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b42a16374481d92aed73ae45b1f120207d8e71d24fb89f357fadbd8f946fd84b" +dependencies = [ + "dbus", + "futures-util", + "num", + "once_cell", + "rand 0.8.5", +] + [[package]] name = "dbus-tokio" version = "0.7.6" @@ -976,6 +989,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "keyring" +version = "3.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1961983669d57bdfe6c0f3ef8e4c229b5ef751afcc7d87e4271d2f71f6ccfa8b" +dependencies = [ + "dbus-secret-service", + "log", +] + [[package]] name = "kordophone" version = "0.1.0" @@ -1031,6 +1054,7 @@ dependencies = [ "directories", "env_logger", "futures-util", + "keyring", "kordophone", "kordophone-db", "log", @@ -1190,12 +1214,76 @@ dependencies = [ "tempfile", ] +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + [[package]] name = "num-conv" version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -1388,14 +1476,35 @@ version = "5.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "74765f6d916ee2faa39bc8e68e4f3ed8949b48cccdac59983d287a7cb71ce9c5" +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + [[package]] name = "rand" version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9fbfd9d094a40bf3ae768db9361049ace4c0e04a4fd6b359518bd7b73a73dd97" dependencies = [ - "rand_chacha", - "rand_core", + "rand_chacha 0.9.0", + "rand_core 0.9.3", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", ] [[package]] @@ -1405,7 +1514,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.9.3", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.14", ] [[package]] @@ -1925,7 +2043,7 @@ dependencies = [ "http 1.3.1", "httparse", "log", - "rand", + "rand 0.9.1", "sha1", "thiserror 2.0.12", "utf-8", @@ -1974,7 +2092,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "458f7a779bf54acc9f347480ac654f68407d3aab21269a6e3c9f922acd9e2da9" dependencies = [ "getrandom 0.3.2", - "rand", + "rand 0.9.1", "uuid-macro-internal", ] diff --git a/kordophone/src/api/http_client.rs b/kordophone/src/api/http_client.rs index 63d2522..bfdf0ec 100644 --- a/kordophone/src/api/http_client.rs +++ b/kordophone/src/api/http_client.rs @@ -53,7 +53,8 @@ pub enum Error { ClientError(String), HTTPError(hyper::Error), SerdeError(serde_json::Error), - DecodeError, + DecodeError(String), + Unauthorized, } impl std::error::Error for Error { @@ -192,7 +193,7 @@ impl APIInterface for HTTPAPIClient { let body = || -> Body { serde_json::to_string(&credentials).unwrap().into() }; let token: AuthResponse = self.request_with_body_retry("authenticate", Method::POST, body, false).await?; - let token = JwtToken::new(&token.jwt).map_err(|_| Error::DecodeError)?; + let token = JwtToken::new(&token.jwt).map_err(|e| Error::DecodeError(e.to_string()))?; log::debug!("Saving token: {:?}", token); self.auth_store.set_token(token.clone()).await; @@ -239,7 +240,6 @@ impl APIInterface for HTTPAPIClient { } async fn open_event_socket(&mut self) -> Result { - use tungstenite::http::StatusCode; use tungstenite::handshake::client::Request as TungsteniteRequest; use tungstenite::handshake::client::generate_key; @@ -259,21 +259,45 @@ impl APIInterface for HTTPAPIClient { .body(()) .expect("Unable to build websocket request"); + match &auth { + Some(token) => { + let header_value = token.to_header_value().to_str().unwrap().parse().unwrap(); // ugh + request.headers_mut().insert("Authorization", header_value); + } + None => { + log::warn!(target: "websocket", "Proceeding without auth token."); + } + } + log::debug!("Websocket request: {:?}", request); - if let Some(token) = &auth { - let header_value = token.to_header_value().to_str().unwrap().parse().unwrap(); // ugh - request.headers_mut().insert("Authorization", header_value); + match connect_async(request).await.map_err(Error::from) { + Ok((socket, response)) => { + log::debug!("Websocket connected: {:?}", response.status()); + Ok(WebsocketEventSocket::new(socket)) + } + Err(e) => match e { + Error::ClientError(ce) => match ce.as_str() { + "HTTP error: 401 Unauthorized" | "Unauthorized" => { + // Try to authenticate + if let Some(credentials) = &self.auth_store.get_credentials().await { + log::warn!("Websocket connection failed, attempting to authenticate"); + let new_token = self.authenticate(credentials.clone()).await?; + self.auth_store.set_token(new_token).await; + + // try again on the next attempt. + return Err(Error::Unauthorized); + } else { + log::error!("Websocket unauthorized, no credentials provided"); + return Err(Error::ClientError("Unauthorized, no credentials provided".into())); + } + } + _ => Err(Error::Unauthorized) + } + + _ => Err(e) + } } - - let (socket, response) = connect_async(request).await.map_err(Error::from)?; - log::debug!("Websocket connected: {:?}", response.status()); - - if response.status() != StatusCode::SWITCHING_PROTOCOLS { - return Err(Error::ClientError("Websocket connection failed".into())); - } - - Ok(WebsocketEventSocket::new(socket)) } } @@ -384,7 +408,7 @@ impl HTTPAPIClient { // If JSON deserialization fails, try to interpret it as plain text // Unfortunately the server does return things like this... - let s = str::from_utf8(&body).map_err(|_| Error::DecodeError)?; + let s = str::from_utf8(&body).map_err(|e| Error::DecodeError(e.to_string()))?; serde_plain::from_str(s).map_err(|_| json_err) } }?; diff --git a/kordophone/src/model/jwt.rs b/kordophone/src/model/jwt.rs index 8ef5683..9521dac 100644 --- a/kordophone/src/model/jwt.rs +++ b/kordophone/src/model/jwt.rs @@ -26,11 +26,31 @@ enum ExpValue { #[derive(Deserialize, Serialize, Debug, Clone)] #[allow(dead_code)] struct JwtPayload { - exp: serde_json::Value, + #[serde(deserialize_with = "deserialize_exp")] + exp: i64, iss: Option, user: Option, } +fn deserialize_exp<'de, D>(deserializer: D) -> Result +where + D: serde::Deserializer<'de>, +{ + use serde::de::Error; + + #[derive(Deserialize)] + #[serde(untagged)] + enum ExpValue { + String(String), + Number(i64), + } + + match ExpValue::deserialize(deserializer)? { + ExpValue::String(s) => s.parse().map_err(D::Error::custom), + ExpValue::Number(n) => Ok(n), + } +} + #[derive(Deserialize, Serialize, Debug, Clone)] #[allow(dead_code)] pub struct JwtToken { @@ -62,13 +82,7 @@ impl JwtToken { let payload: JwtPayload = serde_json::from_slice(&payload)?; // Parse jwt expiration date - // Annoyingly, because of my own fault, this could be either an integer or string. - let exp: i64 = payload.exp.as_i64().unwrap_or_else(|| { - let exp: String = payload.exp.as_str().unwrap().to_string(); - exp.parse().unwrap() - }); - - let timestamp = DateTime::from_timestamp(exp, 0).unwrap().naive_utc(); + let timestamp = DateTime::from_timestamp(payload.exp, 0).unwrap().naive_utc(); let expiration_date = DateTime::from_naive_utc_and_offset(timestamp, Utc); Ok(JwtToken { @@ -84,9 +98,19 @@ impl JwtToken { // STUPID: My mock server uses a different encoding than the real server, so we have to // try both encodings here. - Self::decode_token_using_engine(token, general_purpose::STANDARD).or( + log::debug!("Attempting to decode JWT token: {}", token); + + let result = Self::decode_token_using_engine(token, general_purpose::STANDARD).or( Self::decode_token_using_engine(token, general_purpose::URL_SAFE_NO_PAD), - ) + ); + + if let Err(ref e) = result { + log::error!("Failed to decode JWT token: {}", e); + log::error!("Token length: {}", token.len()); + log::error!("Token parts: {:?}", token.split('.').collect::>()); + } + + result } pub fn dummy() -> Self { @@ -96,7 +120,7 @@ impl JwtToken { typ: "JWT".to_string(), }, payload: JwtPayload { - exp: serde_json::Value::Null, + exp: 0, iss: None, user: None, }, diff --git a/kordophoned/Cargo.toml b/kordophoned/Cargo.toml index 7891e35..775a381 100644 --- a/kordophoned/Cargo.toml +++ b/kordophoned/Cargo.toml @@ -13,6 +13,7 @@ dbus-tree = "0.9.2" directories = "6.0.0" env_logger = "0.11.6" futures-util = "0.3.31" +keyring = { version = "3.6.2", features = ["sync-secret-service"] } kordophone = { path = "../kordophone" } kordophone-db = { path = "../kordophone-db" } log = "0.4.25" diff --git a/kordophoned/include/net.buzzert.kordophonecd.Server.xml b/kordophoned/include/net.buzzert.kordophonecd.Server.xml index 01fb1c3..7623714 100644 --- a/kordophoned/include/net.buzzert.kordophonecd.Server.xml +++ b/kordophoned/include/net.buzzert.kordophonecd.Server.xml @@ -80,12 +80,6 @@ - - - - - diff --git a/kordophoned/src/daemon/auth_store.rs b/kordophoned/src/daemon/auth_store.rs index 5956b66..ff58749 100644 --- a/kordophoned/src/daemon/auth_store.rs +++ b/kordophoned/src/daemon/auth_store.rs @@ -2,6 +2,7 @@ use crate::daemon::SettingsKey; use std::sync::Arc; use tokio::sync::Mutex; +use keyring::{Entry, Result}; use kordophone::api::{AuthenticationStore, http_client::Credentials}; use kordophone::model::JwtToken; @@ -22,6 +23,8 @@ impl DatabaseAuthenticationStore { #[async_trait] impl AuthenticationStore for DatabaseAuthenticationStore { async fn get_credentials(&mut self) -> Option { + use keyring::secret_service::SsCredential; + self.database.lock().await.with_settings(|settings| { let username: Option = settings.get::(SettingsKey::USERNAME) .unwrap_or_else(|e| { @@ -29,31 +32,39 @@ impl AuthenticationStore for DatabaseAuthenticationStore { None }); - // TODO: This would be the point where we map from credential item to password. - let password: String = settings.get::(SettingsKey::CREDENTIAL_ITEM) - .unwrap_or_else(|e| { - log::warn!("error getting password from database: {}", e); - None - }) - .unwrap_or_else(|| { - log::warn!("warning: no password in database, [DEBUG] using default password"); - "test".to_string() - }); + match username { + Some(username) => { + let credential = SsCredential::new_with_target(None, "net.buzzert.kordophonecd", &username).unwrap(); - if username.is_none() { - log::warn!("Username not present in database"); - } + let password: Result = Entry::new_with_credential(Box::new(credential)) + .get_password(); - match (username, password) { - (Some(username), password) => Some(Credentials { username, password }), - _ => None, + log::debug!("password: {:?}", password); + + match password { + Ok(password) => Some(Credentials { username, password }), + Err(e) => { + log::error!("error getting password from keyring: {}", e); + None + } + } + } + None => None, } }).await } async fn get_token(&mut self) -> Option { self.database.lock().await - .with_settings(|settings| settings.get::(SettingsKey::TOKEN).unwrap_or_default()).await + .with_settings(|settings| { + match settings.get::(SettingsKey::TOKEN) { + Ok(token) => token, + Err(e) => { + log::warn!("Failed to get token from settings: {}", e); + None + } + } + }).await } async fn set_token(&mut self, token: JwtToken) { diff --git a/kordophoned/src/daemon/mod.rs b/kordophoned/src/daemon/mod.rs index 399df4d..4a6e656 100644 --- a/kordophoned/src/daemon/mod.rs +++ b/kordophoned/src/daemon/mod.rs @@ -51,6 +51,7 @@ pub mod target { pub static SETTINGS: &str = "settings"; pub static UPDATES: &str = "updates"; } + pub struct Daemon { pub event_sender: Sender, event_receiver: Receiver, diff --git a/kordophoned/src/daemon/settings.rs b/kordophoned/src/daemon/settings.rs index 18e9482..4d78d93 100644 --- a/kordophoned/src/daemon/settings.rs +++ b/kordophoned/src/daemon/settings.rs @@ -4,7 +4,6 @@ use anyhow::Result; pub mod keys { pub static SERVER_URL: &str = "ServerURL"; pub static USERNAME: &str = "Username"; - pub static CREDENTIAL_ITEM: &str = "CredentialItem"; pub static TOKEN: &str = "Token"; } @@ -13,7 +12,6 @@ pub mod keys { pub struct Settings { pub server_url: Option, pub username: Option, - pub credential_item: Option, pub token: Option, } @@ -21,12 +19,10 @@ impl Settings { pub fn from_db(db_settings: &mut DbSettings) -> Result { let server_url: Option = db_settings.get(keys::SERVER_URL)?; let username: Option = db_settings.get(keys::USERNAME)?; - let credential_item: Option = db_settings.get(keys::CREDENTIAL_ITEM)?; let token: Option = db_settings.get(keys::TOKEN)?; Ok(Self { server_url, username, - credential_item, token, }) } @@ -38,9 +34,6 @@ impl Settings { if let Some(username) = &self.username { db_settings.put(keys::USERNAME, &username)?; } - if let Some(credential_item) = &self.credential_item { - db_settings.put(keys::CREDENTIAL_ITEM, &credential_item)?; - } if let Some(token) = &self.token { db_settings.put(keys::TOKEN, &token)?; } diff --git a/kordophoned/src/dbus/server_impl.rs b/kordophoned/src/dbus/server_impl.rs index 2dc8a38..2abc10d 100644 --- a/kordophoned/src/dbus/server_impl.rs +++ b/kordophoned/src/dbus/server_impl.rs @@ -115,7 +115,6 @@ impl DbusSettings for ServerImpl { Event::UpdateSettings(Settings { server_url: Some(url), username: Some(user), - credential_item: None, token: None, }, r) ) @@ -131,7 +130,6 @@ impl DbusSettings for ServerImpl { Event::UpdateSettings(Settings { server_url: Some(value), username: None, - credential_item: None, token: None, }, r) ) @@ -147,28 +145,10 @@ impl DbusSettings for ServerImpl { Event::UpdateSettings(Settings { server_url: None, username: Some(value), - credential_item: None, token: None, }, r) ) } - - fn credential_item(&self) -> Result, dbus::MethodErr> { - self.send_event_sync(Event::GetAllSettings) - .map(|settings| settings.credential_item.unwrap_or_default()).map(|item| dbus::Path::new(item).unwrap_or_default()) - } - - fn set_credential_item(&self, value: dbus::Path<'static>) -> Result<(), dbus::MethodErr> { - self.send_event_sync(|r| - Event::UpdateSettings(Settings { - server_url: None, - username: None, - credential_item: Some(value.to_string()), - token: None, - }, r) - ) - } - } fn run_sync_future(f: F) -> Result diff --git a/kpcli/src/daemon/mod.rs b/kpcli/src/daemon/mod.rs index 26bafd9..9dc9af6 100644 --- a/kpcli/src/daemon/mod.rs +++ b/kpcli/src/daemon/mod.rs @@ -70,11 +70,6 @@ pub enum ConfigCommands { SetUsername { username: String, }, - - /// Sets the credential item. - SetCredentialItem { - item: String, - }, } impl Commands { @@ -180,19 +175,16 @@ impl DaemonCli { ConfigCommands::Print => self.print_settings().await, ConfigCommands::SetServerUrl { url } => self.set_server_url(url).await, ConfigCommands::SetUsername { username } => self.set_username(username).await, - ConfigCommands::SetCredentialItem { item } => self.set_credential_item(item).await, } } pub async fn print_settings(&mut self) -> Result<()> { let server_url = KordophoneSettings::server_url(&self.proxy()).unwrap_or_default(); let username = KordophoneSettings::username(&self.proxy()).unwrap_or_default(); - let credential_item = KordophoneSettings::credential_item(&self.proxy()).unwrap_or_default(); let table = table!( [ b->"Server URL", &server_url ], - [ b->"Username", &username ], - [ b->"Credential Item", &credential_item ] + [ b->"Username", &username ] ); table.printstd(); @@ -209,11 +201,6 @@ impl DaemonCli { .map_err(|e| anyhow::anyhow!("Failed to set username: {}", e)) } - pub async fn set_credential_item(&mut self, item: String) -> Result<()> { - KordophoneSettings::set_credential_item(&self.proxy(), item.into()) - .map_err(|e| anyhow::anyhow!("Failed to set credential item: {}", e)) - } - pub async fn delete_all_conversations(&mut self) -> Result<()> { KordophoneRepository::delete_all_conversations(&self.proxy()) .map_err(|e| anyhow::anyhow!("Failed to delete all conversations: {}", e)) diff --git a/kpcli/src/db/mod.rs b/kpcli/src/db/mod.rs index ecd5ce8..15b58ea 100644 --- a/kpcli/src/db/mod.rs +++ b/kpcli/src/db/mod.rs @@ -93,15 +93,17 @@ struct DbClient { impl DbClient { fn database_path() -> PathBuf { - let temp_dir = env::temp_dir(); - temp_dir.join("kpcli_chat.db") + env::var("KORDOPHONE_DB_PATH").unwrap_or_else(|_| { + let temp_dir = env::temp_dir(); + temp_dir.join("kpcli_chat.db").to_str().unwrap().to_string() + }).into() } pub fn new() -> Result { let path = Self::database_path(); let path_str: &str = path.as_path().to_str().unwrap(); - println!("kpcli: Using temporary db at {}", path_str); + println!("kpcli: Using db at {}", path_str); let db = Database::new(path_str)?; Ok( Self { database: db })