From 1746314e7d8ef4e06cc05951dc620d88d280d26e Mon Sep 17 00:00:00 2001 From: edouardparis Date: Tue, 29 Apr 2025 15:14:53 +0200 Subject: [PATCH] Add liana-connect cache file --- Cargo.lock | 16 ++ liana-gui/Cargo.toml | 1 + liana-gui/src/app/settings.rs | 17 +- liana-gui/src/installer/mod.rs | 45 ++++- .../services/connect/client/backend/mod.rs | 53 ++---- .../src/services/connect/client/cache.rs | 159 ++++++++++++++++++ liana-gui/src/services/connect/client/mod.rs | 1 + liana-gui/src/services/connect/login.rs | 124 +++++--------- 8 files changed, 290 insertions(+), 126 deletions(-) create mode 100644 liana-gui/src/services/connect/client/cache.rs diff --git a/Cargo.lock b/Cargo.lock index 3d7adba1..bc90d070 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -254,6 +254,21 @@ dependencies = [ "slab", ] +[[package]] +name = "async-fd-lock" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7569377d7062165f6f7834d9cb3051974a2d141433cc201c2f94c149e993cccf" +dependencies = [ + "async-trait", + "cfg-if", + "pin-project", + "rustix", + "thiserror 1.0.69", + "tokio", + "windows-sys 0.52.0", +] + [[package]] name = "async-fs" version = "2.1.2" @@ -2993,6 +3008,7 @@ dependencies = [ name = "liana-gui" version = "10.0.0" dependencies = [ + "async-fd-lock", "async-hwi", "async-trait", "backtrace", diff --git a/liana-gui/Cargo.toml b/liana-gui/Cargo.toml index 2a049d20..bf96d225 100644 --- a/liana-gui/Cargo.toml +++ b/liana-gui/Cargo.toml @@ -29,6 +29,7 @@ iced_runtime = "0.13.1" email_address = "0.2.7" tokio = {version = "1.21.0", features = ["signal"]} +async-fd-lock = "0.2.0" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" diff --git a/liana-gui/src/app/settings.rs b/liana-gui/src/app/settings.rs index 8bd0c82a..fc741685 100644 --- a/liana-gui/src/app/settings.rs +++ b/liana-gui/src/app/settings.rs @@ -66,7 +66,21 @@ impl Settings { pub struct AuthConfig { pub email: String, pub wallet_id: String, - pub refresh_token: String, + // legacy field, refresh_token is now stored in the connect cache file + // Keep it in case, user want to open the wallet with a previous Liana-GUI version. + // Field cannot be ignored as the settings file is override during settings update. + // TODO: remove later after multiple versions. + pub refresh_token: Option, +} + +impl AuthConfig { + pub fn new(email: String, wallet_id: String) -> Self { + Self { + email, + wallet_id, + refresh_token: None, + } + } } #[derive(Debug, Clone, Deserialize, Serialize)] @@ -241,7 +255,6 @@ pub enum SettingsError { WritingFile(String), Unexpected(String), } - impl std::fmt::Display for SettingsError { fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { match self { diff --git a/liana-gui/src/installer/mod.rs b/liana-gui/src/installer/mod.rs index d1502105..31239d13 100644 --- a/liana-gui/src/installer/mod.rs +++ b/liana-gui/src/installer/mod.rs @@ -13,6 +13,7 @@ use liana_ui::{ widget::{Column, Element}, }; use lianad::config::Config; +use std::ops::Deref; use tracing::{error, info, warn}; use std::io::Write; @@ -37,6 +38,7 @@ use crate::{ api::payload::{Provider, ProviderKey}, BackendClient, BackendWalletClient, }, + cache::update_connect_cache, }, }, signer::Signer, @@ -550,6 +552,22 @@ pub async fn create_remote_wallet( info!("Settings file created"); + let backend = remote_backend.inner_client(); + if let Err(e) = update_connect_cache( + &network_datadir, + backend.auth.read().await.deref(), + backend.auth_client(), + false, + ) + .await + { + // this error is not critical, the liana-connect backend stored the wallet + // and user can reauthenticate. + tracing::error!("Failed to update Liana-Connect cache: {}", e); + } else { + info!("Liana-Connect cache updated"); + }; + Ok(gui_config_path) } @@ -599,6 +617,22 @@ pub async fn import_remote_wallet( info!("Gui configuration file created"); + let backend = backend.inner_client(); + if let Err(e) = update_connect_cache( + &network_datadir, + backend.auth.read().await.deref(), + backend.auth_client(), + false, + ) + .await + { + // this error is not critical, the liana-connect backend stored the wallet + // and user can reauthenticate. + tracing::error!("Failed to update Liana-Connect cache: {}", e); + } else { + info!("Liana-Connect cache updated"); + }; + Ok(gui_config_path) } @@ -631,19 +665,16 @@ pub async fn extract_remote_gui_settings(ctx: &Context, backend: &BackendWalletC .expect("LianaDescriptor.to_string() always include the checksum") .to_string(); - let auth = backend.inner_client().auth.read().await; - Settings { wallets: vec![WalletSetting { name: wallet_name(descriptor), descriptor_checksum, keys: Vec::new(), hardware_wallets: Vec::new(), - remote_backend_auth: Some(AuthConfig { - email: backend.user_email().to_string(), - wallet_id: backend.wallet_id(), - refresh_token: auth.refresh_token.clone(), - }), + remote_backend_auth: Some(AuthConfig::new( + backend.user_email().to_string(), + backend.wallet_id(), + )), }], } } diff --git a/liana-gui/src/services/connect/client/backend/mod.rs b/liana-gui/src/services/connect/client/backend/mod.rs index f81daf3c..ba58f713 100644 --- a/liana-gui/src/services/connect/client/backend/mod.rs +++ b/liana-gui/src/services/connect/client/backend/mod.rs @@ -20,7 +20,6 @@ use reqwest::{Error, IntoUrl, Method, RequestBuilder, Response}; use tokio::sync::RwLock; use crate::{ - app::settings::{AuthConfig, Settings}, daemon::{model::*, Daemon, DaemonBackend, DaemonError}, dir::LianaDirectory, hw::HardwareWalletConfig, @@ -28,7 +27,10 @@ use crate::{ use self::api::{UTXOKind, DEFAULT_OUTPOINTS_LIMIT}; -use super::auth::{self, AccessTokenResponse, AuthError}; +use super::{ + auth::{self, AccessTokenResponse, AuthError}, + cache::update_connect_cache, +}; impl From for DaemonError { fn from(value: Error) -> Self { @@ -101,6 +103,10 @@ impl BackendClient { }) } + pub fn auth_client(&self) -> &auth::AuthClient { + &self.auth_client + } + pub fn user_email(&self) -> &str { &self.auth_client.email } @@ -532,45 +538,20 @@ impl Daemon for BackendWalletClient { return Ok(()); } Ok(mut old) => { - let new = self - .inner - .auth_client - .refresh_token(&auth.refresh_token) - .await?; - let network_dir = datadir.network_directory(network); - let mut settings = Settings::from_file(&network_dir).map_err(|e| { - DaemonError::Unexpected(format!( - "Cannot access to settings.json file: {}", - e - )) - })?; - if let Some(wallet_settings) = settings.wallets.iter_mut().find(|w| { - if let Some(auth) = &w.remote_backend_auth { - auth.wallet_id == self.wallet_uuid - } else { - false - } - }) { - wallet_settings.remote_backend_auth = Some(AuthConfig { - email: self.inner.auth_client.email.clone(), - wallet_id: self.wallet_id(), - refresh_token: new.refresh_token.clone(), - }); - } else { - tracing::info!("Wallet id was not found in the settings"); - } - - settings.to_file(&network_dir).map_err(|e| { - DaemonError::Unexpected(format!( - "Cannot access to settings.json file: {}", - e - )) + let new = update_connect_cache( + &network_dir, + &old, + &self.inner.auth_client, + true, // refresh the token + ) + .await + .map_err(|e| { + DaemonError::Unexpected(format!("Cannot update Liana-connect cache: {}", e)) })?; *old = new; - tracing::info!("Liana backend access was refreshed"); } } } diff --git a/liana-gui/src/services/connect/client/cache.rs b/liana-gui/src/services/connect/client/cache.rs new file mode 100644 index 00000000..1a5885a7 --- /dev/null +++ b/liana-gui/src/services/connect/client/cache.rs @@ -0,0 +1,159 @@ +use crate::dir::NetworkDirectory; +use async_fd_lock::LockWrite; +use serde::{Deserialize, Serialize}; +use std::io::SeekFrom; +use tokio::fs::OpenOptions; +use tokio::io::AsyncSeekExt; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; + +use super::auth::{AccessTokenResponse, AuthClient, AuthError}; + +pub const CONNECT_CACHE_FILENAME: &str = "connect.json"; + +#[derive(Debug, Default, Clone, Deserialize, Serialize)] +pub struct ConnectCache { + pub accounts: Vec, +} + +impl ConnectCache { + fn upsert_credential(&mut self, email: &str, tokens: AccessTokenResponse) { + if let Some(c) = self.accounts.iter_mut().find(|c| c.email == email) { + c.tokens = tokens; + } else { + self.accounts.push(Account { + email: email.to_string(), + tokens, + }) + } + } +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct Account { + pub email: String, + pub tokens: AccessTokenResponse, +} + +impl Account { + pub fn from_cache( + network_dir: &NetworkDirectory, + email: &str, + ) -> Result, ConnectCacheError> { + let mut path = network_dir.path().to_path_buf(); + path.push(CONNECT_CACHE_FILENAME); + + std::fs::read(path) + .map_err(|e| match e.kind() { + std::io::ErrorKind::NotFound => ConnectCacheError::NotFound, + _ => ConnectCacheError::ReadingFile(format!("Reading settings file: {}", e)), + }) + .and_then(|file_content| { + serde_json::from_slice::(&file_content).map_err(|e| { + ConnectCacheError::ReadingFile(format!("Parsing settings file: {}", e)) + }) + }) + .map(|cache| cache.accounts.into_iter().find(|c| c.email == email)) + } +} + +pub async fn update_connect_cache( + network_dir: &NetworkDirectory, + current_tokens: &AccessTokenResponse, + client: &AuthClient, + refresh: bool, +) -> Result { + let email = &client.email; + let mut path = network_dir.path().to_path_buf(); + path.push(CONNECT_CACHE_FILENAME); + + let file_exists = tokio::fs::try_exists(&path).await.unwrap_or(false); + + let mut file = OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(false) + .open(&path) + .await + .map_err(|e| ConnectCacheError::ReadingFile(format!("Opening file: {}", e)))? + .lock_write() + .await + .map_err(|e| ConnectCacheError::ReadingFile(format!("Locking file: {:?}", e)))?; + + let mut cache = if file_exists { + let mut file_content = Vec::new(); + file.read_to_end(&mut file_content) + .await + .map_err(|e| ConnectCacheError::ReadingFile(format!("Reading file content: {}", e)))?; + + match serde_json::from_slice::(&file_content) { + Ok(cache) => cache, + Err(e) => { + tracing::warn!("Something wrong with Liana-Connect cache file: {:?}", e); + tracing::warn!("Liana-Connect cache file is reset"); + ConnectCache::default() + } + } + } else { + ConnectCache::default() + }; + + if let Some(c) = cache.accounts.iter().find(|cred| cred.email == *email) { + // An other process updated the tokens + if current_tokens.expires_at < c.tokens.expires_at { + tracing::debug!("Liana-Connect authentication tokens are up to date, nothing to do"); + return Ok(c.tokens.clone()); + } + } + + let tokens = if refresh { + client + .refresh_token(¤t_tokens.refresh_token) + .await + .map_err(ConnectCacheError::Updating)? + } else { + current_tokens.clone() + }; + + cache.upsert_credential(email, tokens.clone()); + + let content = serde_json::to_vec_pretty(&cache).map_err(|e| { + ConnectCacheError::WritingFile(format!("Failed to serialize settings: {}", e)) + })?; + + file.seek(SeekFrom::Start(0)).await.map_err(|e| { + ConnectCacheError::WritingFile(format!("Failed to seek to start of file: {}", e)) + })?; + + file.write_all(&content).await.map_err(|e| { + tracing::warn!("failed to write to file: {:?}", e); + ConnectCacheError::WritingFile(e.to_string()) + })?; + + file.inner_mut() + .set_len(content.len() as u64) + .await + .map_err(|e| ConnectCacheError::WritingFile(format!("Failed to truncate file: {}", e)))?; + + Ok(tokens) +} + +#[derive(Debug, Clone)] +pub enum ConnectCacheError { + NotFound, + ReadingFile(String), + WritingFile(String), + Unexpected(String), + Updating(AuthError), +} +impl std::fmt::Display for ConnectCacheError { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + match self { + Self::NotFound => write!(f, "ConnectCache file not found"), + Self::ReadingFile(e) => write!(f, "Error while reading file: {}", e), + Self::WritingFile(e) => write!(f, "Error while writing file: {}", e), + Self::Unexpected(e) => write!(f, "Unexpected error: {}", e), + Self::Updating(e) => write!(f, "Error while updating cache file: {}", e), + } + } +} diff --git a/liana-gui/src/services/connect/client/mod.rs b/liana-gui/src/services/connect/client/mod.rs index 1eb4add2..eda54292 100644 --- a/liana-gui/src/services/connect/client/mod.rs +++ b/liana-gui/src/services/connect/client/mod.rs @@ -1,5 +1,6 @@ pub mod auth; pub mod backend; +pub mod cache; use liana::miniscript::bitcoin; diff --git a/liana-gui/src/services/connect/login.rs b/liana-gui/src/services/connect/login.rs index e1dce45b..2c872637 100644 --- a/liana-gui/src/services/connect/login.rs +++ b/liana-gui/src/services/connect/login.rs @@ -13,7 +13,7 @@ use lianad::commands::ListCoinsResult; use crate::{ app::{ cache::coins_to_cache, - settings::{AuthConfig, Settings, SettingsError, WalletSetting}, + settings::{Settings, SettingsError}, }, daemon::DaemonError, dir::LianaDirectory, @@ -22,15 +22,18 @@ use crate::{ use super::client::{ auth::{AuthClient, AuthError}, backend::{api, BackendClient, BackendWalletClient}, + cache::{self, update_connect_cache, ConnectCacheError}, }; #[derive(Debug, Clone)] pub enum Error { Auth(AuthError), + CredentialsMissing, // DaemonError does not implement Clone. // TODO: maybe Arc is overkill Backend(Arc), Settings(SettingsError), + Cache(cache::ConnectCacheError), Unexpected(String), } @@ -38,8 +41,10 @@ impl std::fmt::Display for Error { fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { match self { Self::Auth(e) => write!(f, "Authentication error: {}", e), + Self::CredentialsMissing => write!(f, "credentials missing"), Self::Backend(e) => write!(f, "Remote backend error: {}", e), Self::Settings(e) => write!(f, "Settings file error: {}", e), + Self::Cache(e) => write!(f, "Connect cache file error: {}", e), Self::Unexpected(e) => write!(f, "Unexpected error: {}", e), } } @@ -63,6 +68,12 @@ impl From for Error { } } +impl From for Error { + fn from(value: ConnectCacheError) -> Self { + Self::Cache(value) + } +} + #[derive(Debug, Clone)] pub enum Message { View(ViewMessage), @@ -156,13 +167,13 @@ impl LianaLiteLogin { }, Task::none(), ), - Ok(auth_config) => ( + Ok(setting) => ( Self { network, - datadir, + datadir: datadir.clone(), step: ConnectionStep::CheckingAuthFile, connection_error: None, - wallet_id: auth_config.wallet_id.clone(), + wallet_id: setting.wallet_id.clone(), auth_error: None, processing: true, }, @@ -174,14 +185,14 @@ impl LianaLiteLogin { let client = AuthClient::new( service_config.auth_api_url, service_config.auth_api_public_key, - auth_config.email, + setting.email, ); - connect_with_refresh_token( + connect_with_credentials( client, - auth_config.refresh_token, - auth_config.wallet_id, + setting.wallet_id, service_config.backend_api_url, network, + datadir, ) .await }, @@ -327,9 +338,11 @@ impl LianaLiteLogin { self.auth_error = None; let wallet_id = self.wallet_id.clone(); let network = self.network; + let datadir = self.datadir.clone(); return Task::perform( async move { - connect(client, otp, wallet_id, backend_api_url, network).await + connect(client, otp, wallet_id, backend_api_url, network, datadir) + .await }, Message::Connected, ); @@ -343,21 +356,8 @@ impl LianaLiteLogin { return Task::perform(async move { Some(client) }, Message::Install); } Ok(BackendState::WalletExists(client, wallet, coins)) => { - let datadir = self.datadir.clone(); - let network = self.network; return Task::perform( - async move { - update_wallet_auth_settings( - datadir, - network, - wallet.clone(), - client.user_email().to_string(), - client.auth().await.refresh_token, - ) - .await?; - - Ok((client, wallet, coins)) - }, + async move { Ok((client, wallet, coins)) }, Message::Run, ); } @@ -494,68 +494,20 @@ impl LianaLiteLogin { } } -async fn update_wallet_auth_settings( - datadir: LianaDirectory, - network: Network, - wallet: api::Wallet, - email: String, - refresh_token: String, -) -> Result<(), Error> { - let network_dir = datadir.network_directory(network); - let mut settings = Settings::from_file(&network_dir)?; - - let descriptor_checksum = wallet - .descriptor - .to_string() - .split_once('#') - .map(|(_, checksum)| checksum) - .expect("Failed to get checksum from a valid LianaDescriptor") - .to_string(); - - let remote_backend_auth = Some(AuthConfig { - email, - wallet_id: wallet.id.clone(), - refresh_token, - }); - - if let Some(wallet_settings) = settings.wallets.iter_mut().find(|w| { - if let Some(auth) = &w.remote_backend_auth { - auth.wallet_id == wallet.id - } else { - false - } - }) { - wallet_settings.remote_backend_auth = remote_backend_auth; - } else { - tracing::info!("Wallet id was not found in the settings, adding now the wallet settings to the settings.json file"); - settings.wallets.insert( - 0, - WalletSetting { - name: wallet.name, - descriptor_checksum, - keys: Vec::new(), - hardware_wallets: Vec::new(), - remote_backend_auth, - }, - ); - } - - settings.to_file(&network_dir).map_err(|e| { - DaemonError::Unexpected(format!("Cannot access to settings.json file: {}", e)) - })?; - - Ok(()) -} - pub async fn connect( auth: AuthClient, token: String, wallet_id: String, backend_api_url: String, network: Network, + liana_directory: LianaDirectory, ) -> Result { + let network_dir = liana_directory.network_directory(network); let access = auth.verify_otp(token.trim_end()).await?; - let client = BackendClient::connect(auth, backend_api_url, access.clone(), network).await?; + let client = + BackendClient::connect(auth.clone(), backend_api_url, access.clone(), network).await?; + + update_connect_cache(&network_dir, &access, &auth, false).await?; let wallets = client.list_wallets().await?; if wallets.is_empty() { @@ -566,25 +518,35 @@ pub async fn connect( let first = wallets.first().cloned().ok_or(DaemonError::NoAnswer)?; let (wallet_client, wallet) = client.connect_wallet(first); let coins = coins_to_cache(Arc::new(wallet_client.clone())).await?; + Ok(BackendState::WalletExists(wallet_client, wallet, coins)) } else if let Some(wallet) = wallets.into_iter().find(|w| w.id == wallet_id) { let (wallet_client, wallet) = client.connect_wallet(wallet); let coins = coins_to_cache(Arc::new(wallet_client.clone())).await?; + Ok(BackendState::WalletExists(wallet_client, wallet, coins)) } else { Ok(BackendState::NoWallet(client)) } } -pub async fn connect_with_refresh_token( +pub async fn connect_with_credentials( auth: AuthClient, - refresh_token: String, wallet_id: String, backend_api_url: String, network: Network, + liana_directory: LianaDirectory, ) -> Result { - let access = auth.refresh_token(&refresh_token).await?; - let client = BackendClient::connect(auth, backend_api_url, access.clone(), network).await?; + let network_dir = liana_directory.network_directory(network); + let mut tokens = cache::Credential::from_cache(&network_dir, &auth.email)? + .ok_or(Error::CredentialsMissing)? + .tokens; + + if tokens.expires_at < chrono::Utc::now().timestamp() { + tokens = cache::update_connect_cache(&network_dir, &tokens, &auth, true).await?; + } + + let client = BackendClient::connect(auth, backend_api_url, tokens, network).await?; if let Some(wallet) = client .list_wallets()