commit 46065585cc43769e2e2eaf3bb09778278dfe605e
parent 40f381ca93fa66176452caeb5909d7ee8d9c0ca6
Author: Antoine A <>
Date: Tue, 12 May 2026 17:14:31 +0200
bank: account API
Diffstat:
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(),
+ ¶ms,
+ )
+ .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,
+ ¶ms,
+ )
+ .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, ¤c_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,
+ ¶ms.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) = ¶ms.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,
+ ¶ms.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) = ¶ms.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" }