libeufin

Integration and sandbox testing for FinTech APIs and data formats
Log | Files | Refs | Submodules | README | LICENSE

commit 46065585cc43769e2e2eaf3bb09778278dfe605e
parent 40f381ca93fa66176452caeb5909d7ee8d9c0ca6
Author: Antoine A <>
Date:   Tue, 12 May 2026 17:14:31 +0200

bank: account API

Diffstat:
MCargo.lock | 56+++++++++++++++++++++++++++++++++-----------------------
Mcrates/libeufin-bank/src/api.rs | 106+++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------------
Mcrates/libeufin-bank/src/api/account.rs | 482++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Mcrates/libeufin-bank/src/api/tan.rs | 2+-
Mcrates/libeufin-bank/src/api/tx.rs | 12++++++++----
Mcrates/libeufin-bank/src/auth.rs | 8++++++--
Mcrates/libeufin-bank/src/db.rs | 1+
Mcrates/libeufin-bank/src/db/account.rs | 274++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
Acrates/libeufin-bank/src/db/gc.rs | 77+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/libeufin-bank/src/mfa.rs | 39+++++++++++++++++++++++++++++++++++----
Mcrates/libeufin-bank/src/payto.rs | 25+++++++++++++++++++++++--
Mcrates/libeufin-ebics/Cargo.toml | 2+-
12 files changed, 980 insertions(+), 104 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -289,6 +289,15 @@ dependencies = [ ] [[package]] +name = "bit-vec" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" +dependencies = [ + "serde", +] + +[[package]] name = "bitflags" version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -336,9 +345,9 @@ checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" [[package]] name = "calamine" -version = "0.34.0" +version = "0.35.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20ae05a4e39297eecf9a994210d27501318c37a9318201f8e11050add82bb6f0" +checksum = "8822fe6253ca47aa5ad9a3be09f6fe7cd20c6a74e41b0aa42e8f4e3d523508df" dependencies = [ "atoi_simd", "byteorder", @@ -362,9 +371,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.61" +version = "1.2.62" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d16d90359e986641506914ba71350897565610e87ce0ad9e6f28569db3dd5c6d" +checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" dependencies = [ "find-msvc-tools", "jobserver", @@ -1175,9 +1184,9 @@ dependencies = [ [[package]] name = "hashbrown" -version = "0.17.0" +version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" [[package]] name = "hashlink" @@ -1274,9 +1283,9 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" [[package]] name = "hybrid-array" -version = "0.4.11" +version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08d46837a0ed51fe95bd3b05de33cd64a1ee88fc797477ca48446872504507c5" +checksum = "9155a582abd142abc056962c29e3ce5ff2ad5469f4246b537ed42c5deba857da" dependencies = [ "typenum", ] @@ -1489,7 +1498,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown 0.17.0", + "hashbrown 0.17.1", "serde", "serde_core", ] @@ -2174,9 +2183,9 @@ dependencies = [ [[package]] name = "quick-xml" -version = "0.39.3" +version = "0.39.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "721da970c312655cde9b4ffe0547f20a8494866a4af5ff51f18b7c633d0c870b" +checksum = "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e" dependencies = [ "encoding_rs", "memchr", @@ -2337,9 +2346,9 @@ checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" [[package]] name = "rcgen" -version = "0.14.7" +version = "0.14.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10b99e0098aa4082912d4c649628623db6aba77335e4f4569ff5083a6448b32e" +checksum = "57f6d249aad744e274e682777a50283a225a32705394ee6d5fcc01efa25e4055" dependencies = [ "aws-lc-rs", "pem", @@ -2760,9 +2769,9 @@ dependencies = [ [[package]] name = "serde_with" -version = "3.19.0" +version = "3.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f05839ce67618e14a09b286535c0d9c94e85ef25469b0e13cb4f844e5593eb19" +checksum = "e72c1c2cb7b223fafb600a619537a871c2818583d619401b785e7c0b746ccde2" dependencies = [ "serde_core", "serde_with_macros", @@ -2770,9 +2779,9 @@ dependencies = [ [[package]] name = "serde_with_macros" -version = "3.19.0" +version = "3.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf2ebbe86054f9b45bc3881e865683ccfaccce97b9b4cb53f3039d67f355a334" +checksum = "b90c488738ecb4fb0262f41f43bc40efc5868d9fb744319ddf5f5317f417bfac" dependencies = [ "darling", "proc-macro2", @@ -3422,9 +3431,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.52.2" +version = "1.52.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "110a78583f19d5cdb2c5ccf321d1290344e71313c6c37d43520d386027d18386" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" dependencies = [ "bytes", "libc", @@ -4436,10 +4445,11 @@ checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" [[package]] name = "yasna" -version = "0.5.2" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e17bb3549cc1321ae1296b9cdc2698e2b6cb1992adfa19a8c72e5b7a738f44cd" +checksum = "b5f6765e852b9b4dc8e2a76843e4d64d1cea8e79bcde0b6901aea8e7c7f08282" dependencies = [ + "bit-vec", "time", ] @@ -4488,9 +4498,9 @@ dependencies = [ [[package]] name = "zerofrom" -version = "0.1.7" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" dependencies = [ "zerofrom-derive", ] diff --git a/crates/libeufin-bank/src/api.rs b/crates/libeufin-bank/src/api.rs @@ -48,7 +48,10 @@ pub mod test { use taler_common::{ config::Config, error_code::ErrorCode, - types::amount::{Amount, Decimal}, + types::{ + amount::{Amount, Decimal}, + payto::{IbanPayto, PaytoURI}, + }, }; use taler_test_utils::{ db::db_test_setup, @@ -72,7 +75,7 @@ pub mod test { config::BankCfg, db::{self, account::CreationResult}, mfa::TALER_CHALLENGE_IDS, - payto::{FullBankPayto, LibeufinId}, + payto::BankPayto, }; pub enum Auth { @@ -84,12 +87,12 @@ pub mod test { } pub struct BankTestCtx { - pub merchant_payto: FullBankPayto, - pub exchange_payto: FullBankPayto, - pub customer_payto: FullBankPayto, - pub unknown_payto: FullBankPayto, - pub tmp_payto: FullBankPayto, - pub admin_payto: FullBankPayto, + pub merchant_payto: BankPayto, + pub exchange_payto: BankPayto, + pub customer_payto: BankPayto, + pub unknown_payto: BankPayto, + pub tmp_payto: BankPayto, + pub admin_payto: BankPayto, pub db: PgPool, pub server: Router, tokens: BTreeMap<CompactString, String>, @@ -125,7 +128,7 @@ pub mod test { None, None, None, - LibeufinId::IBAN(rand_iban_payto().into_inner()), + rand_iban_payto().into_inner().into(), false, false, Decimal::new(10, 0).to_amount(&state.cfg.regional_currency), @@ -147,7 +150,7 @@ pub mod test { None, None, None, - LibeufinId::IBAN(rand_iban_payto().into_inner()), + rand_iban_payto().into_inner().into(), false, false, Decimal::new(10, 0).to_amount(&state.cfg.regional_currency), @@ -169,7 +172,7 @@ pub mod test { None, None, None, - LibeufinId::IBAN(rand_iban_payto().into_inner()), + rand_iban_payto().into_inner().into(), false, false, Decimal::new(10, 0).to_amount(&state.cfg.regional_currency), @@ -192,18 +195,12 @@ pub mod test { }; let mut ctx = BankTestCtx { - merchant_payto, - exchange_payto, - customer_payto, - unknown_payto: FullBankPayto::new( - LibeufinId::IBAN(rand_iban_payto().into_inner()), - "Unknown", - ), - tmp_payto: FullBankPayto::new( - LibeufinId::IBAN(rand_iban_payto().into_inner()), - "Unknown", - ), - admin_payto: admin_payto, + merchant_payto: merchant_payto.into(), + exchange_payto: exchange_payto.into(), + customer_payto: customer_payto.into(), + unknown_payto: rand_iban_payto().convert(), + tmp_payto: rand_iban_payto().convert(), + admin_payto: admin_payto.into(), server, db, tokens: BTreeMap::new(), @@ -278,7 +275,13 @@ pub mod test { } } - fn requesta(&self, method: Method, path: &str, username: Option<&str>) -> TestRequest { + fn requesta( + &self, + method: Method, + path: impl AsRef<str>, + username: Option<&str>, + ) -> TestRequest { + let path = path.as_ref(); let username = username.unwrap_or_else(|| Self::extract_username(path)); let token = &self.tokens[username]; self.server @@ -286,43 +289,44 @@ pub mod test { .header(AUTHORIZATION, token) } - pub fn postpw(&self, path: &str) -> TestRequest { + pub fn postpw(&self, path: impl AsRef<str>) -> TestRequest { Self::pw_auth(self.server.request(Method::POST, path), None) } - pub fn geta(&self, path: &str) -> TestRequest { + pub fn geta(&self, path: impl AsRef<str>) -> TestRequest { self.requesta(Method::GET, path, None) } - pub fn posta(&self, path: &str) -> TestRequest { + pub fn posta(&self, path: impl AsRef<str>) -> TestRequest { self.requesta(Method::POST, path, None) } - pub fn patcha(&self, path: &str) -> TestRequest { + pub fn patcha(&self, path: impl AsRef<str>) -> TestRequest { self.requesta(Method::PATCH, path, None) } - pub fn deletea(&self, path: &str) -> TestRequest { + pub fn deletea(&self, path: impl AsRef<str>) -> TestRequest { self.requesta(Method::DELETE, path, None) } - pub fn get_admin(&self, path: &str) -> TestRequest { + pub fn get_admin(&self, path: impl AsRef<str>) -> TestRequest { self.requesta(Method::GET, path, Some("admin")) } - pub fn post_admin(&self, path: &str) -> TestRequest { + pub fn post_admin(&self, path: impl AsRef<str>) -> TestRequest { self.requesta(Method::POST, path, Some("admin")) } - pub fn patch_admin(&self, path: &str) -> TestRequest { + pub fn patch_admin(&self, path: impl AsRef<str>) -> TestRequest { self.requesta(Method::PATCH, path, Some("admin")) } - pub fn delete_admin(&self, path: &str) -> TestRequest { + pub fn delete_admin(&self, path: impl AsRef<str>) -> TestRequest { self.requesta(Method::DELETE, path, Some("admin")) } - pub async fn auth_routine(&self, method: Method, path: &str, auth: Auth) { + pub async fn auth_routine(&self, method: Method, path: impl AsRef<str>, auth: Auth) { + let path = path.as_ref(); // Bad header self.request(method.clone(), path) .header(AUTHORIZATION, "WTF") @@ -371,6 +375,30 @@ pub mod test { .assert_no_content(); } + pub async fn tmp_payto(&mut self) -> PaytoURI { + self.tmp_payto = rand_iban_payto().convert(); + self.tmp_payto.as_uri() + } + + /** Perform a bank transaction of [amount] [from] account [to] account with [subject} */ + pub async fn tx(&self, from: &str, amount: &str, to: &str) { + let payto = match to { + "admin" => &self.admin_payto, + "merchant" => &self.merchant_payto, + "customer" => &self.customer_payto, + _ => &self.tmp_payto, + }; + self.posta(format!("/accounts/{from}/transactions")) + .json(json!({ + "payto_uri": format!("{payto}?message=payout"), + "amount": amount, + })) + .await + .maybe_challenge(&self) + .await + .assert_ok(); + } + pub async fn create_conversion_rate_class(&self) -> u64 { self.post_admin("/conversion-rate-classes") .json(json!({ @@ -451,6 +479,8 @@ pub mod test { fn assert_challenge(&self, ctx: &BankTestCtx) -> impl std::future::Future<Output = Self> { self.assert_challenge_check(ctx, async |_| {}) } + + fn maybe_challenge(self, ctx: &BankTestCtx) -> impl std::future::Future<Output = Self>; } impl MfaRequest for TestResponse { @@ -500,5 +530,13 @@ pub mod test { .raw_json(self.req_body.clone()) .await } + + async fn maybe_challenge(self, ctx: &BankTestCtx) -> Self { + if self.status == StatusCode::ACCEPTED { + self.assert_challenge(ctx).await + } else { + self + } + } } } diff --git a/crates/libeufin-bank/src/api/account.rs b/crates/libeufin-bank/src/api/account.rs @@ -27,7 +27,7 @@ use axum::{ extract::State, http::StatusCode, response::{IntoResponse, NoContent}, - routing::{patch, post}, + routing::{get, patch, post}, }; use compact_str::CompactString; use regex::Regex; @@ -35,7 +35,7 @@ use serde::{Deserialize, Serialize}; use sqlx::{Database, PgPool}; use taler_api::{ error::{ApiResult, bad_request, failure, failure_code, failure_status}, - extract::Req, + extract::{Query, Req}, }; use taler_common::{ error_code::ErrorCode::{self}, @@ -51,10 +51,16 @@ use taler_macros::EnumMeta; use crate::{ TanChannel, api::{BankState, conversion::ConversionRate}, - auth::{RegistrationAuth, UserAuth, UserRAuth}, + auth::{AdminRAuth, RegistrationAuth, UserAuth, UserRAuth, require_admin}, config::{BankCfg, WireMethod}, - db::account::{CreationResult, PatchResult, by_username, reconfig}, - mfa::{AccountReconfigOp, MfaReq, Tans}, + db::{ + self, + account::{ + AccountParams, CreationResult, DeletionResult, PatchAuthResult, PatchResult, + by_username, page_admin, page_public, reconfig, reconfig_password, + }, + }, + mfa::{AccountDeletionOp, AccountPasswordOp, AccountReconfigOp, MfaReq, Tans}, payto::{FullBankPayto, LibeufinId, XTalerBank}, pw::checkpw, }; @@ -352,7 +358,7 @@ pub enum AccountStatus { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct AccountData { pub name: CompactString, - pub payto_uri: PaytoURI, + pub payto_uri: FullBankPayto, pub balance: Balance, pub debit_threshold: Amount, pub contact_data: ChallengeContactData, @@ -367,6 +373,47 @@ pub struct AccountData { pub conversion_rate: Option<ConversionRate>, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct AccountMinimalData { + pub username: CompactString, + pub name: CompactString, + pub payto_uri: FullBankPayto, + pub balance: Balance, + pub debit_threshold: Amount, + pub is_public: bool, + pub is_taler_exchange: bool, + pub is_locked: bool, + pub row_id: u64, + pub status: AccountStatus, + pub conversion_rate_class_id: Option<u64>, + pub conversion_rate: Option<ConversionRate>, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ListBankAccountsResponse { + pub accounts: Vec<AccountMinimalData>, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PublicAccount { + pub username: CompactString, + pub payto_uri: FullBankPayto, + pub balance: Balance, + pub is_taler_exchange: bool, + pub row_id: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PublicAccountsResponse { + pub public_accounts: Vec<PublicAccount>, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct AccountPasswordChange { + pub new_password: CompactString, + pub old_password: Option<CompactString>, +} + pub fn account_api() -> Router<Arc<BankState>> { Router::new() .route( @@ -394,6 +441,49 @@ pub fn account_api() -> Router<Arc<BankState>> { })), } }, + ) + .get( + async |State(state): State<Arc<BankState>>, + _: AdminRAuth, + Query(params): Query<AccountParams>| { + let params = params.check()?; + let accounts = page_admin( + &state.db, + &state.cfg.ctx, + &state.cfg.regional_currency, + state.cfg.fiat_currency(), + &params, + ) + .await?; + if accounts.is_empty() { + ApiResult::Ok(NoContent.into_response()) + } else { + Ok(Json(ListBankAccountsResponse { accounts }).into_response()) + } + }, + ), + ) + .route( + "/public-accounts", + get( + async |State(state): State<Arc<BankState>>, Query(params): Query<AccountParams>| { + let params = params.check()?; + let accounts = page_public( + &state.db, + &state.cfg.ctx, + &state.cfg.regional_currency, + &params, + ) + .await?; + if accounts.is_empty() { + ApiResult::Ok(NoContent.into_response()) + } else { + Ok(Json(PublicAccountsResponse { + public_accounts: accounts, + }) + .into_response()) + } + }, ), ) .route( @@ -441,6 +531,39 @@ pub fn account_api() -> Router<Arc<BankState>> { } }, ) + .delete( + async |State(state): State<Arc<BankState>>, + MfaReq { mut auth, mfa, .. }: MfaReq<AccountDeletionOp>| { + if !state.cfg.allow_account_deletion && !auth.is_admin() { + return Err(require_admin()); + } + // Not deleting reserved names + if matches!(auth.username.as_str(), "admin" | "bank") { + return Err(failure( + ErrorCode::BANK_RESERVED_USERNAME_CONFLICT, + "Cannot delete reserved account", + )); + } else if auth.username == "exchange" && state.cfg.allow_conversion { + return Err(failure( + ErrorCode::BANK_RESERVED_USERNAME_CONFLICT, + "Cannot delete 'exchange' accounts when conversion is enabled", + )); + } + match db::account::delete(&state.db, &auth.username, mfa.is_2fa()).await? { + DeletionResult::Success => Ok(NoContent.into_response()), + DeletionResult::UnknownAccount => { + Err(failure_code(ErrorCode::BANK_UNKNOWN_ACCOUNT)) + } + DeletionResult::BalanceNotZero => { + Err(failure_code(ErrorCode::BANK_ACCOUNT_BALANCE_NOT_ZERO)) + } + DeletionResult::TanRequired => Ok(mfa + .response_mfa(&mut auth, &state.db, &state.cfg.ctx) + .await + .into_response()), + } + }, + ) .get( async |State(state): State<Arc<BankState>>, UserAuth { username, .. }: UserRAuth| { @@ -459,6 +582,43 @@ pub fn account_api() -> Router<Arc<BankState>> { }, ), ) + .route( + "/accounts/{username}/auth", + patch( + async |State(state): State<Arc<BankState>>, + MfaReq { mut auth, req, mfa }: MfaReq<AccountPasswordOp>| { + if !auth.is_admin() && req.old_password.is_none() { + return Err(failure_code( + ErrorCode::BANK_NON_ADMIN_PATCH_MISSING_OLD_PASSWORD, + )); + } + checkpw(&req.new_password, state.cfg.pwd_check_quality)?; + + match reconfig_password( + &state.db, + &state.cfg.pw_crypto, + &auth.username, + &req.new_password, + req.old_password.as_deref(), + auth.is_admin() || mfa.is_2fa(), + ) + .await? + { + PatchAuthResult::UnknownAccount => { + Err(failure_code(ErrorCode::BANK_UNKNOWN_ACCOUNT)) + } + PatchAuthResult::OldPasswordMismatch => { + Err(failure_code(ErrorCode::BANK_PATCH_BAD_OLD_PASSWORD)) + } + PatchAuthResult::TanRequired => Ok(mfa + .response_mfa(&mut auth, &state.db, &state.cfg.ctx) + .await? + .into_response()), + PatchAuthResult::Success => Ok(NoContent.into_response()), + } + }, + ), + ) } pub fn rand_iban_payto() -> IbanPayto { @@ -564,7 +724,7 @@ pub async fn create_account( WireMethod::iban => { if let Some(payto) = &req.payto_uri { let bank_id = payto.expect_iban()?; - Ok(create(LibeufinId::IBAN(bank_id.clone())).await?) + Ok(create(LibeufinId::IBAN(*bank_id)).await?) } else { let mut retry = 5; loop { @@ -696,7 +856,10 @@ pub async fn patch_account( #[cfg(test)] pub mod test { + use std::time::Duration; + use axum::http::{Method, StatusCode}; + use jiff::Timestamp; use serde::Serialize; use taler_common::{ error_code::ErrorCode, @@ -706,17 +869,20 @@ pub mod test { }, }; use taler_test_utils::{json, server::TestServer as _}; + use tracing_subscriber::fmt::format; use crate::{ TanChannel, api::{ account::{ - AccountData, AccountStatus, Balance, ChallengeContactData, CreditDebitInfo, Maybe, - RegisterAccountResponse, rand_iban_payto, + AccountData, AccountStatus, Balance, ChallengeContactData, CreditDebitInfo, + ListBankAccountsResponse, Maybe, PublicAccountsResponse, RegisterAccountResponse, + rand_iban_payto, }, test::{Auth, BankTestCtx, MfaRequest, bank_setup, bank_setup_conf}, }, - payto::LibeufinId, + db::gc::collect, + payto::{FullBankPayto, LibeufinId}, }; #[tokio::test] @@ -771,18 +937,18 @@ pub mod test { } let name = "Jane"; - let payto = rand_iban_payto(); + let payto = &rand_iban_payto(); let req = json!({ "username": "foo", "password": "password", "name": name, "is_public": true, - "payto_uri": payto, + "payto_uri": &payto, "is_taler_exchange": true }); // Check given payto { - let full = FullPayto::new(LibeufinId::IBAN(payto.clone().into_inner()), name); + let full: FullBankPayto = payto.full(name).convert(); // Check ok assert_eq!( full, @@ -978,7 +1144,7 @@ pub mod test { "username": "cashout_guess", "password": "cashout_guess-password", "name": "Mr Guess My Name", - "cashout_payto_uri": payto + "cashout_payto_uri": &payto })) .await .assert_ok(); @@ -1099,7 +1265,7 @@ pub mod test { is_locked: false, is_public: true, is_taler_exchange: true, - payto_uri: new.as_full_uri("John Smith"), + payto_uri: new.full("John Smith").convert(), status: AccountStatus::active, tan_channel: Some(TanChannel::sms), tan_channels: vec![TanChannel::sms, TanChannel::email] @@ -1185,6 +1351,78 @@ pub mod test { .assert_ok(); } + #[tokio::test] + async fn delete() { + let mut ctx = bank_setup().await; + ctx.auth_routine(Method::DELETE, "/accounts/customer", Auth::Token) + .await; + + // Reserved accounts + for username in ["admin", "bank"] { + ctx.delete_admin(format!("/accounts/{username}")) + .await + .assert_error(ErrorCode::BANK_RESERVED_USERNAME_CONFLICT); + } + ctx.deletea("/accounts/exchange") + .await + .assert_error(ErrorCode::BANK_RESERVED_USERNAME_CONFLICT); + + let payto = ctx.tmp_payto().await; + ctx.post("/accounts") + .json(json!({ + "username": "john", + "password": "john-password", + "name": "John", + "payto_uri": payto + })) + .await + .assert_ok(); + ctx.cache_tokens(&["john"]).await; + ctx.fill_tan_info("john").await; + // Fail to delete, due to a non-zero balance. + ctx.tx("customer", "KUDOS:1", "john").await; + ctx.deletea("/accounts/john") + .await + .assert_error(ErrorCode::BANK_ACCOUNT_BALANCE_NOT_ZERO); + // Successful deletion + ctx.tx("john", "KUDOS:1", "customer").await; + ctx.deletea("/accounts/john") + .await + .assert_challenge(&ctx) + .await + .assert_no_content(); + // Account no longer exists + ctx.deletea("/accounts/john") + .await + .assert_error(ErrorCode::GENERIC_TOKEN_UNKNOWN); + ctx.delete_admin("/accounts/john") + .await + .assert_error(ErrorCode::BANK_UNKNOWN_ACCOUNT); + + // GC + { + let zero = Duration::default(); + collect(&ctx.db, &Timestamp::now(), &zero, &zero, &zero) + .await + .unwrap(); + // TODO need more operations + } + + // Test admin-only account deletion + let ctx = ctx.swap_cfg("test_restrict.conf"); + ctx.auth_routine(Method::DELETE, "/accounts/merchant", Auth::Admin) + .await; + // Exchange is still restricted + ctx.delete_admin("/accounts/exchange") + .await + .assert_error(ErrorCode::BANK_RESERVED_USERNAME_CONFLICT); + + // Test delete exchange account + let ctx = ctx.swap_cfg("test_no_conversion.conf"); + // Exchange is no longer restricted + ctx.deletea("/accounts/exchange").await.assert_no_content(); + } + async fn check_admin_only(ctx: &BankTestCtx, req: impl Serialize, error: ErrorCode) { // Check restricted ctx.patcha("/accounts/merchant") @@ -1293,7 +1531,7 @@ pub mod test { is_locked: false, is_public: true, is_taler_exchange: true, - payto_uri: ctx.merchant_payto.as_full_uri("Roger"), + payto_uri: ctx.merchant_payto.clone().into_inner().full("Roger"), status: AccountStatus::active, tan_channel: None, tan_channels: Vec::new(), @@ -1427,4 +1665,216 @@ pub mod test { .await .assert_error(ErrorCode::BANK_TAN_CHANNEL_NOT_SUPPORTED); } + + #[tokio::test] + async fn password() { + let ctx = bank_setup().await; + ctx.auth_routine(Method::PATCH, "/accounts/customer/auth", Auth::User) + .await; + + // Changing the password + ctx.patcha("/accounts/customer/auth") + .json(json!({ + "old_password": "customer-password", + "new_password": "new-password" + })) + .await + .assert_no_content(); + // Previous password should fail. + ctx.post("/accounts/customer/token") + .basic_auth("customer", "customer-password") + .await + .assert_status(StatusCode::UNAUTHORIZED); + // New password should succeed + ctx.post("/accounts/customer/token") + .basic_auth("customer", "new-password") + .json(json!({ "scope": "readonly" })) + .await + .assert_ok(); + ctx.patcha("/accounts/customer/auth") + .json(json!({ + "old_password": "new-password", + "new_password": "customer-password" + })) + .await + .assert_no_content(); + + // Check require test old password + ctx.patcha("/accounts/customer/auth") + .json(json!({ + "old_password": "bad-password", + "new_password": "new-password" + })) + .await + .assert_error(ErrorCode::BANK_PATCH_BAD_OLD_PASSWORD); + // Check require old password for user + ctx.patcha("/accounts/customer/auth") + .json(json!({ + "new_password": "new-password" + })) + .await + .assert_error(ErrorCode::BANK_NON_ADMIN_PATCH_MISSING_OLD_PASSWORD); + // Testing short password + ctx.patcha("/accounts/customer/auth") + .json(json!({ + "old_password": "ignored", + "new_password": "short" + })) + .await + .assert_error(ErrorCode::BANK_PASSWORD_TOO_SHORT); + // Testing long password + ctx.patcha("/accounts/customer/auth") + .json(json!({ + "old_password": "ignored", + "new_password": "loooooooooooooooooooooooooooooooooooooooooooooooooooooooooooong-password" + })) + .await + .assert_error(ErrorCode::BANK_PASSWORD_TOO_LONG); + + // Check admin + ctx.patch_admin("/accounts/customer/auth") + .json(json!({ + "new_password": "customer-password" + })) + .await + .assert_no_content(); + + // Check 2FA + ctx.fill_tan_info("customer").await; + ctx.patcha("/accounts/customer/auth") + .json(json!({ + "old_password": "customer-password", + "new_password": "new-password" + })) + .await + .assert_challenge(&ctx) + .await + .assert_no_content(); + ctx.patch_admin("/accounts/customer/auth") + .json(json!({ + "new_password": "customer-password" + })) + .await + .assert_no_content(); + + // Check 2FA after password check + ctx.patcha("/accounts/customer/auth") + .json(json!({ + "old_password": "bad-password", + "new_password": "new-password" + })) + .await + .assert_error(ErrorCode::BANK_PATCH_BAD_OLD_PASSWORD); + + // No password check + let ctx = ctx.swap_cfg("test_no_password_check.conf"); + ctx.patcha("/accounts/merchant/auth") + .json(json!({ + "old_password": "merchant-password", + "new_password": "short" + })) + .await + .assert_no_content(); + // Testing long password + ctx.patcha("/accounts/merchant/auth") + .json(json!({ + "old_password": "short", + "new_password": "loooooooooooooooooooooooooooooooooooooooooooooooooooooooooooong-password" + })) + .await + .assert_no_content(); + } + + #[tokio::test] + async fn list() { + let ctx = bank_setup_conf("test_no_conversion.conf").await; + ctx.auth_routine(Method::GET, "/accounts", Auth::Admin) + .await; + + // Remove default accounts + let accounts = ["merchant", "exchange", "customer"]; + for username in accounts { + ctx.delete_admin(format!("/accounts/{username}")) + .await + .assert_no_content(); + } + let req: ListBankAccountsResponse = ctx.get_admin("/accounts").await.assert_ok_json(); + for account in req.accounts { + assert_eq!(account.conversion_rate, None); + if accounts.contains(&account.username.as_str()) { + assert_eq!(account.status, AccountStatus::deleted); + } else { + assert_eq!(account.status, AccountStatus::active); + } + } + + // Hard delete accounts + let zero = Duration::default(); + collect(&ctx.db, &Timestamp::now(), &zero, &zero, &zero) + .await + .unwrap(); + ctx.get("/public-accounts").await.assert_no_content(); + ctx.get_admin("/accounts").await.assert_ok(); + + for _ in 0..3 { + ctx.create_conversion_rate_class().await; + } + + // Gen some public and private accounts + for i in 0..5 { + let m = i % 3; + ctx.post_admin("/accounts") + .json(json!({ + "username": format!("{i}"), + "password": "password", + "name": format!("Mr 1{i}"), + "is_public": i % 2 == 0, + "conversion_rate_class_id": if m > 0 && m <= 3 { + Some(m) + } else { + None + } + })) + .await + .assert_ok(); + } + // All public + let res: PublicAccountsResponse = ctx.get("/public-accounts").await.assert_ok_json(); + assert_eq!(res.public_accounts.len(), 3); + for acc in res.public_accounts { + assert_eq!(0, acc.username.parse::<u8>().unwrap() % 2) + } + // Conversion rate + let res: ListBankAccountsResponse = ctx.get_admin("/accounts").await.assert_ok_json(); + for acc in res.accounts { + // TODO finish rate conversion + } + // Filtering + let check_ids = async |query: &str, ids: &[&str]| { + let res = ctx.get_admin(format!("/accounts?{query}")).await; + if ids.is_empty() { + res.assert_no_content(); + } else { + let res: ListBankAccountsResponse = res.assert_ok_json(); + assert_eq!( + ids, + res.accounts + .into_iter() + .map(|it| it.username) + .collect::<Vec<_>>() + ) + } + }; + check_ids("", &["4", "3", "2", "1", "0", "admin"]).await; + check_ids("filter_name=1", &["4", "3", "2", "1", "0"]).await; + check_ids("filter_name=3", &["3"]).await; + check_ids("conversion_rate_class_id=1", &["4", "1"]).await; + check_ids("conversion_rate_class_id=2", &["2"]).await; + check_ids("conversion_rate_class_id=3", &[]).await; + check_ids("conversion_rate_class_id=4", &[]).await; + check_ids("conversion_rate_class_id=0", &["3", "0", "admin"]).await; + check_ids("conversion_rate_class_id=0&filter_name=1", &["3", "0"]).await; + + // TODO finish rate conversion + } } diff --git a/crates/libeufin-bank/src/api/tan.rs b/crates/libeufin-bank/src/api/tan.rs @@ -494,7 +494,7 @@ pub mod test { let tx_challenge = async || { ctx.posta("/accounts/merchant/transactions") .json(json!({ - "payto_uri": format!("{}&message=tx&amount=KUDOS:0.1", ctx.customer_payto) + "payto_uri": format!("{}?message=tx&amount=KUDOS:0.1", ctx.customer_payto) })) .await .assert_accepted_json::<ChallengeResponse>() diff --git a/crates/libeufin-bank/src/api/tx.rs b/crates/libeufin-bank/src/api/tx.rs @@ -25,18 +25,22 @@ use axum::{Json, Router, extract::State, response::IntoResponse, routing::post}; use jiff::Timestamp; use serde::{Deserialize, Serialize}; use taler_api::error::{bad_request, failure, failure_code}; -use taler_common::{api_common::ShortHashCode, error_code::ErrorCode, types::amount::Amount}; +use taler_common::{ + api_common::ShortHashCode, + error_code::ErrorCode, + types::{amount::Amount, payto::ParsedPayto}, +}; use crate::{ api::BankState, db::tx::{TxResult, create}, mfa::{BankTxOp, MfaReq}, - payto::{BankPayto, TransferBankPayto}, + payto::{BankPayto, LibeufinId}, }; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct TransactionCreateRequest { - pub payto_uri: TransferBankPayto, + pub payto_uri: ParsedPayto<LibeufinId>, pub amount: Option<Amount>, pub request_uid: Option<ShortHashCode>, } @@ -127,7 +131,7 @@ pub mod test { .await; let valid_req = json!({ - "payto_uri": format!("{}&message=payout", ctx.exchange_payto), + "payto_uri": format!("{}?message=payout", ctx.exchange_payto), "amount": "KUDOS:0.3" }); diff --git a/crates/libeufin-bank/src/auth.rs b/crates/libeufin-bank/src/auth.rs @@ -252,7 +252,7 @@ impl<S: UserAuthScope> FromRequestParts<Arc<BankState>> for UserAuth<S> { match S::KIND { AuthKind::AdminOnly => { if !info.is_admin() { - return Err(forbidden("Only administrator allowed")); + return Err(require_admin()); } } AuthKind::UserOnly => { @@ -325,7 +325,7 @@ impl<S: RootAuthScope> FromRequestParts<Arc<BankState>> for AdminAuth<S> { .await?; if !info.is_admin() { - return Err(forbidden("Only administrator allowed")); + return Err(require_admin()); } Ok(Self { scope: PhantomData }) @@ -442,3 +442,7 @@ async fn auth_request( )), } } + +pub fn require_admin() -> ApiError { + forbidden("Only administrator allowed") +} diff --git a/crates/libeufin-bank/src/db.rs b/crates/libeufin-bank/src/db.rs @@ -21,6 +21,7 @@ const SCHEMA: &str = "libeufin_bank"; pub mod account; pub mod conversion; +pub mod gc; pub mod tan; pub mod token; pub mod tx; diff --git a/crates/libeufin-bank/src/db/account.rs b/crates/libeufin-bank/src/db/account.rs @@ -14,23 +14,30 @@ TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ -use compact_str::CompactString; +use compact_str::{CompactString, format_compact}; use jiff::Timestamp; -use sqlx::{PgPool, QueryBuilder, Row as _, postgres::PgRow}; +use serde::Deserialize; +use sqlx::{ + Arguments, PgPool, QueryBuilder, Row as _, + postgres::{PgArguments, PgRow}, +}; use taler_api::{ - db::{BindHelper as _, PgError, TypeHelper as _}, + db::{BindHelper as _, PgError, TypeHelper as _, page}, error::ApiResult, }; -use taler_common::types::{ - amount::{Amount, Currency}, - payto::{BankID, IbanPayto}, +use taler_common::{ + api_params::{Page, PageParams, ParamsErr}, + types::{ + amount::{Amount, Currency}, + payto::{BankID, IbanPayto}, + }, }; use crate::{ PaytoCtx, TanChannel, api::account::{ - AccountData, AccountReconfiguration, AccountStatus, Balance, ChallengeContactData, - CreditDebitInfo, TanInfo, + AccountData, AccountMinimalData, AccountReconfiguration, AccountStatus, Balance, + ChallengeContactData, CreditDebitInfo, PublicAccount, TanInfo, }, db::conversion::{UserKind, user_rate}, mfa::Tans, @@ -222,7 +229,7 @@ pub async fn create( } /** Result status of account deletion */ -pub enum AccountDeletionResult { +pub enum DeletionResult { Success, UnknownAccount, BalanceNotZero, @@ -234,7 +241,7 @@ pub async fn delete( db: &sqlx::PgPool, username: &str, is2fa: bool, -) -> sqlx::Result<AccountDeletionResult> { +) -> sqlx::Result<DeletionResult> { sqlx::query( " SELECT @@ -249,13 +256,13 @@ pub async fn delete( .bind(is2fa) .try_map(|r: PgRow| { Ok(if r.try_get_flag("out_not_found")? { - AccountDeletionResult::UnknownAccount + DeletionResult::UnknownAccount } else if r.try_get_flag("out_balance_not_zero")? { - AccountDeletionResult::BalanceNotZero + DeletionResult::BalanceNotZero } else if r.try_get_flag("out_tan_required")? { - AccountDeletionResult::TanRequired + DeletionResult::TanRequired } else { - AccountDeletionResult::Success + DeletionResult::Success }) }) .fetch_one(db) @@ -481,6 +488,63 @@ pub async fn reconfig( Ok(PatchResult::Success) } +/** Result status of customer account auth patch */ +pub enum PatchAuthResult { + UnknownAccount, + OldPasswordMismatch, + TanRequired, + Success, +} + +/** Change account [username] password to [newPw] if current match [oldPw] */ +pub async fn reconfig_password( + db: &PgPool, + pw_crypto: &PwCrypto, + username: &str, + new_pw: &str, + old_pw: Option<&str>, + is2fa: bool, +) -> sqlx::Result<PatchAuthResult> { + // TODO use optimistic replace instead of transaction + let mut tx = db.begin().await?; + + let Some((customer_id, currenc_pwh, tan_required)): Option<(i64, String, bool)> = + sqlx::query_as( + " + SELECT customer_id, password_hash, NOT $1 AND cardinality(tan_channels) > 0 + FROM customers WHERE username=$2 AND deleted_at IS NULL + ", + ) + .bind(is2fa) + .bind(username) + .fetch_optional(&mut *tx) + .await? + else { + return Ok(PatchAuthResult::UnknownAccount); + }; + + let res = if let Some(old_pw) = old_pw + && !pw_crypto.checkpw(old_pw, &currenc_pwh).unwrap().matches + { + PatchAuthResult::OldPasswordMismatch + } else if tan_required { + PatchAuthResult::TanRequired + } else { + let new_pwh = pw_crypto.hashpw(new_pw); + sqlx::query( + "UPDATE customers SET password_hash=$1, token_creation_counter=0 WHERE customer_id=$2", + ) + .bind(new_pwh) + .bind(customer_id) + .execute(&mut *tx) + .await?; + PatchAuthResult::Success + }; + + tx.commit().await?; + Ok(res) +} + /** Result status of customer account password check */ pub enum CheckPasswordResult { UnknownAccount, @@ -629,6 +693,7 @@ pub async fn bank_info( .await } +/** Get data of account [username] */ pub async fn by_username( db: &PgPool, ctx: &PaytoCtx, @@ -675,13 +740,12 @@ pub async fn by_username( .bind(MAX_TOKEN_CREATION_ATTEMPTS as i16) .bind(username) .try_map(|r: PgRow| { - let name: CompactString = r.try_get("name")?; let status: AccountStatus = r.try_get("status")?; let channels: Vec<TanChannel> = r.try_get("tan_channels")?; let is_exchange: bool = r.try_get("is_taler_exchange")?; Ok(AccountData { - payto_uri: sql_bank_payto(&r, ctx, "internal_payto", "name")?.as_uri(), + payto_uri: sql_bank_payto(&r, ctx, "internal_payto", "name")?, balance: Balance { amount: r.try_get_amount("balance", regional)?, credit_debit_indicator: if r.try_get("has_debt")? { @@ -705,9 +769,185 @@ pub async fn by_username( status, conversion_rate_class_id: r.try_get_opt_u64("conversion_rate_class_id")?, conversion_rate: user_rate(&r, regional, fiat, UserKind::new(username, is_exchange))?, - name, + name: r.try_get("name")?, }) }) .fetch_optional(db) .await } + +#[derive(Debug, Clone, Deserialize)] +pub struct AccountParams { + #[serde(flatten)] + pub page: PageParams, + pub filter_name: Option<CompactString>, + pub conversion_rate_class_id: Option<u64>, +} + +impl AccountParams { + pub fn check(self) -> Result<Account, ParamsErr> { + Ok(Account { + page: self.page.check()?, + filter_name: self.filter_name.map(|it| format_compact!("%{it}%")), + conversion_rate_class_id: self.conversion_rate_class_id, + }) + } +} + +#[derive(Debug)] +pub struct Account { + pub page: Page, + pub filter_name: Option<CompactString>, + pub conversion_rate_class_id: Option<u64>, +} + +/** Get a page of all public accounts */ +pub async fn page_public( + db: &PgPool, + ctx: &PaytoCtx, + currency: &Currency, + params: &Account, +) -> sqlx::Result<Vec<PublicAccount>> { + page( + db, + &params.page, + "bank_account_id", + || { + let mut builder = QueryBuilder::new( + " + SELECT + balance, + has_debt, + internal_payto, + username, + is_taler_exchange, + name, + bank_account_id + FROM bank_accounts JOIN customers + ON owning_customer_id = customer_id + WHERE is_public=true AND deleted_at IS NULL AND ", + ); + if let Some(pattern) = &params.filter_name { + builder.push("name ILIKE ").push_bind(pattern).push(" AND"); + } + builder + }, + |r: PgRow| { + Ok(PublicAccount { + username: r.try_get("username")?, + payto_uri: sql_bank_payto(&r, ctx, "internal_payto", "name")?, + balance: Balance { + amount: r.try_get_amount("balance", currency)?, + credit_debit_indicator: if r.try_get("has_debt")? { + CreditDebitInfo::debit + } else { + CreditDebitInfo::credit + }, + }, + is_taler_exchange: r.try_get("is_taler_exchange")?, + row_id: r.try_get_u64("bank_account_id")?, + }) + }, + ) + .await +} + +/** Get a page of accounts */ +pub async fn page_admin( + db: &PgPool, + ctx: &PaytoCtx, + regional: &Currency, + fiat: Option<&Currency>, + params: &Account, +) -> sqlx::Result<Vec<AccountMinimalData>> { + page( + db, + &params.page, + "bank_account_id", + || { + let mut args = PgArguments::default(); + args.add(MAX_TOKEN_CREATION_ATTEMPTS as i16).unwrap(); + let mut builder = QueryBuilder::with_arguments( + " + SELECT + username + ,name + ,balance + ,has_debt + ,max_debt + ,is_public + ,is_taler_exchange + ,internal_payto + ,bank_account_id + ,CASE + WHEN deleted_at IS NOT NULL THEN 'deleted' + WHEN token_creation_counter > $1 THEN 'locked' + ELSE 'active' + END as status, + conversion_rate_class_id, + cashin_ratio, + cashin_fee, + cashin_tiny_amount, + cashin_min_amount, + cashin_rounding_mode, + cashout_ratio, + cashout_fee, + cashout_tiny_amount, + cashout_min_amount, + cashout_rounding_mode + FROM bank_accounts + JOIN customers ON owning_customer_id = customer_id + CROSS JOIN LATERAL get_conversion_class_rate(conversion_rate_class_id) + WHERE + ", + args, + ); + if let Some(pattern) = &params.filter_name { + builder.push("name ILIKE ").push_bind(pattern).push(" AND "); + } + if let Some(id) = params.conversion_rate_class_id { + if id == 0 { + builder.push("conversion_rate_class_id IS NULL AND "); + } else { + builder + .push("conversion_rate_class_id=") + .push_bind(id as i64) + .push(" AND "); + } + } + builder + }, + |r: PgRow| { + let status: AccountStatus = r.try_get("status")?; + let is_exchange: bool = r.try_get("is_taler_exchange")?; + let username: CompactString = r.try_get("username")?; + Ok(AccountMinimalData { + row_id: r.try_get_u64("bank_account_id")?, + payto_uri: sql_bank_payto(&r, ctx, "internal_payto", "name")?, + balance: Balance { + amount: r.try_get_amount("balance", regional)?, + credit_debit_indicator: if r.try_get("has_debt")? { + CreditDebitInfo::debit + } else { + CreditDebitInfo::credit + }, + }, + debit_threshold: r.try_get_amount("max_debt", regional)?, + is_public: r.try_get("is_public")?, + is_taler_exchange: is_exchange, + is_locked: status == AccountStatus::locked, + status, + conversion_rate_class_id: r.try_get_opt_u64("conversion_rate_class_id")?, + conversion_rate: user_rate( + &r, + regional, + fiat, + UserKind::new(&username, is_exchange), + )?, + name: r.try_get("name")?, + username, + }) + }, + ) + .await +} diff --git a/crates/libeufin-bank/src/db/gc.rs b/crates/libeufin-bank/src/db/gc.rs @@ -0,0 +1,77 @@ +/* +* This file is part of LibEuFin. +* Copyright (C) 2026 Taler Systems S.A. + +* LibEuFin is free software; you can redistribute it and/or modify +* it under the terms of the GNU Affero General Public License as +* published by the Free Software Foundation; either version 3, or +* (at your option) any later version. + +* LibEuFin is distributed in the hope that it will be useful, but +* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY +* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General +* Public License for more details. + +* You should have received a copy of the GNU Affero General Public +* License along with LibEuFin; see the file COPYING. If not, see +* <http://www.gnu.org/licenses/> +*/ + +//! Data access logic for garbage collection + +use std::time::Duration; + +use jiff::Timestamp; +use sqlx::PgPool; +use taler_api::db::BindHelper; + +/** Run garbage collection */ +pub async fn collect( + db: &PgPool, + now: &Timestamp, + abort_after: &Duration, + clean_after: &Duration, + delete_after: &Duration, +) -> sqlx::Result<()> { + let abort_after = *now - *abort_after; + let clean_after = *now - *clean_after; + let delete_after = *now - *delete_after; + + // Abort pending operations + sqlx::query( + " + UPDATE taler_withdrawal_operations SET aborted = true WHERE creation_date < $1 AND NOT EXISTS( + SELECT FROM prepared_transfers JOIN taler_withdrawal_operations USING (withdrawal_id) + ) + " + ).bind_timestamp(&abort_after).execute(db).await?; + + // Clean aborted operations, expired challenges and expired tokens + for stm in [ + "DELETE FROM taler_withdrawal_operations WHERE aborted = true AND creation_date < $1 AND NOT EXISTS( + SELECT FROM prepared_transfers JOIN taler_withdrawal_operations USING (withdrawal_id) + )", + "DELETE FROM tan_challenges WHERE expiration_date < $1", + "DELETE FROM bearer_tokens WHERE expiration_time < $1" + ] { + sqlx::query(stm).bind_timestamp(&clean_after).execute(db).await?; + } + + // Delete old bank transactions, linked operations are deleted by CASCADE + sqlx::query("DELETE FROM bank_account_transactions WHERE transaction_date < $1") + .bind_timestamp(&delete_after) + .execute(db) + .await?; + + // Hard delete soft deleted customer without bank transactions, bank account are deleted by CASCADE + sqlx::query( + "DELETE FROM customers WHERE deleted_at IS NOT NULL AND NOT EXISTS( + SELECT FROM bank_account_transactions NATURAL JOIN bank_accounts + WHERE owning_customer_id=customer_id + )", + ) + .execute(db) + .await?; + + Ok(()) +} diff --git a/crates/libeufin-bank/src/mfa.rs b/crates/libeufin-bank/src/mfa.rs @@ -17,7 +17,7 @@ * <http://www.gnu.org/licenses/> */ -use std::{marker::PhantomData, str::FromStr as _, sync::Arc, time::Duration}; +use std::{any::TypeId, marker::PhantomData, str::FromStr as _, sync::Arc, time::Duration}; use aws_lc_rs::digest::SHA512; use axum::{ @@ -44,7 +44,7 @@ use crate::{ TanChannel, api::{ BankState, - account::{AccountReconfiguration, TanInfo}, + account::{AccountPasswordChange, AccountReconfiguration, TanInfo}, tan::{Challenge, ChallengeResponse}, token::TokenRequest, tx::TransactionCreateRequest, @@ -75,7 +75,7 @@ pub type Tans = Vec<(TanChannel, CompactString)>; pub trait MfaOp { const OP: Operation; type Scope: UserAuthScope; - type Body: DeserializeOwned + Send; + type Body: DeserializeOwned + Send + 'static; fn required_validation(_: &Self::Body, _: &BankInfo) -> ApiResult<Option<Tans>> { Ok(None) @@ -110,6 +110,22 @@ impl MfaOp for TokenOp { type Body = TokenRequest; } +pub struct AccountDeletionOp; + +impl MfaOp for AccountDeletionOp { + const OP: Operation = Operation::account_delete; + type Scope = UserRWScope; + type Body = Empty; +} + +pub struct AccountPasswordOp; + +impl MfaOp for AccountPasswordOp { + const OP: Operation = Operation::account_auth_reconfig; + type Scope = UserRWScope; + type Body = AccountPasswordChange; +} + fn mfa_body_hash(body: &[u8], salt: &Base32<16>) -> Base32<64> { let mut digest = aws_lc_rs::digest::Context::new(&SHA512); digest.update(salt.as_ref()); @@ -248,7 +264,11 @@ impl<O: MfaOp> FromRequest<Arc<BankState>> for MfaReq<O> { async fn from_request(req: Request, state: &Arc<BankState>) -> Result<Self, Self::Rejection> { let (mut parts, body) = req.into_parts(); let mut auth = UserAuth::from_request_parts(&mut parts, state).await?; - let raw = decompressed_strict_body(&parts.headers, body).await?; + let raw = if TypeId::of::<O::Body>() == TypeId::of::<Empty>() { + Bytes::default() + } else { + decompressed_strict_body(&parts.headers, body).await? + }; let Req(req) = Req::<O::Body>::try_from(&raw)?; // Check if challenges are used let mfa = match parts.headers.get(&TALER_CHALLENGE_IDS) { @@ -329,3 +349,14 @@ pub fn gen_tan_code() -> String { let rand_val: u32 = random_range(0..100000000); format!("{:08}", rand_val) } + +pub struct Empty; + +impl<'de> Deserialize<'de> for Empty { + fn deserialize<D>(_: D) -> Result<Self, D::Error> + where + D: serde::Deserializer<'de>, + { + Ok(Self) + } +} diff --git a/crates/libeufin-bank/src/payto.rs b/crates/libeufin-bank/src/payto.rs @@ -24,7 +24,7 @@ use taler_api::{ error::{ApiResult, bad_request}, }; use taler_common::types::{ - iban::BIC, + iban::{BIC, IBAN}, payto::{ BankID, FullIbanPayto, FullPayto, IbanPayto, Payto, PaytoErr, PaytoImpl, PaytoURI, TransferPayto, @@ -94,7 +94,7 @@ impl LibeufinId { pub fn bank(mut self, name: &str, ctx: &PaytoCtx) -> FullBankPayto { match &mut self { - LibeufinId::IBAN(bank_id) => bank_id.bic = ctx.bic.clone(), + LibeufinId::IBAN(bank_id) => bank_id.bic = ctx.bic, LibeufinId::XTalerBank(xtaler_bank) => xtaler_bank.hostname = ctx.hostname.clone(), }; FullPayto::new(self, name) @@ -138,6 +138,27 @@ impl PaytoImpl for LibeufinId { } } +impl From<BankID> for LibeufinId { + fn from(value: BankID) -> Self { + Self::IBAN(value) + } +} + +impl From<IBAN> for LibeufinId { + fn from(value: IBAN) -> Self { + Self::IBAN(BankID { + iban: value, + bic: None, + }) + } +} + +impl From<XTalerBank> for LibeufinId { + fn from(value: XTalerBank) -> Self { + Self::XTalerBank(value) + } +} + pub struct PaytoCtx { pub bic: Option<BIC>, pub hostname: CompactString, diff --git a/crates/libeufin-ebics/Cargo.toml b/crates/libeufin-ebics/Cargo.toml @@ -41,4 +41,4 @@ rcgen = { version = "0.14.7", features = [ "pem", ], default-features = false } x509-parser = { version = "0.18.1", features = ["verify-aws"] } -calamine = { version = "0.34.0" } +calamine = { version = "0.35.0" }