libeufin

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

commit 8d9b269ef687600804e1649e829c6e785b213311
parent 8398693a23f3774478221940f2ad683da4512b90
Author: Antoine A <>
Date:   Tue,  5 May 2026 14:43:58 +0200

bank: add TAN logic

Diffstat:
MCargo.lock | 47++++++++++++++++++++++++++++++++---------------
MCargo.toml | 5++---
Mcrates/libeufin-bank/Cargo.toml | 3++-
Mcrates/libeufin-bank/src/api.rs | 198+++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------------
Mcrates/libeufin-bank/src/api/account.rs | 439+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------
Acrates/libeufin-bank/src/api/tan.rs | 488+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/libeufin-bank/src/api/token.rs | 36+++++-------------------------------
Mcrates/libeufin-bank/src/auth.rs | 73++++++++++++++++++++++++++++++++++++++++++++-----------------------------
Mcrates/libeufin-bank/src/config.rs | 6+++---
Mcrates/libeufin-bank/src/db.rs | 2++
Mcrates/libeufin-bank/src/db/account.rs | 366+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------
Acrates/libeufin-bank/src/db/tan.rs | 261+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/libeufin-bank/src/db/token.rs | 4++--
Mcrates/libeufin-bank/src/lib.rs | 15+++------------
Acrates/libeufin-bank/src/mfa.rs | 312+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/libeufin-bank/src/payto.rs | 6+++---
Mcrates/libeufin-bank/src/pw.rs | 3++-
Mcrates/libeufin-ebics/Cargo.toml | 1-
Mcrates/libeufin-ebics/src/crypto.rs | 3++-
Mcrates/libeufin-nexus/src/lib.rs | 4++--
Mcrates/libeufin-nexus/src/testing.rs | 4++--
21 files changed, 2049 insertions(+), 227 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -973,6 +973,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" [[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] name = "futures-channel" version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1045,6 +1060,7 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ + "futures-channel", "futures-core", "futures-io", "futures-macro", @@ -1114,9 +1130,9 @@ checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" [[package]] name = "h2" -version = "0.4.13" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" +checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733" dependencies = [ "atomic-waker", "bytes", @@ -1649,10 +1665,11 @@ dependencies = [ "clap", "compact_str", "const_format", - "getrandom 0.4.2", + "futures", "jiff", "libeufin-ebics", "owo-colors", + "rand 0.10.1", "reedline", "regex", "serde", @@ -1683,7 +1700,6 @@ dependencies = [ "compact_str", "flate2", "futures-util", - "getrandom 0.4.2", "jiff", "pretty_assertions", "rand 0.10.1", @@ -1753,7 +1769,7 @@ dependencies = [ "bitflags", "libc", "plain", - "redox_syscall 0.7.4", + "redox_syscall 0.7.5", ] [[package]] @@ -2149,9 +2165,9 @@ dependencies = [ [[package]] name = "quick-xml" -version = "0.39.2" +version = "0.39.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "958f21e8e7ceb5a1aa7fa87fab28e7c75976e0bfe7e23ff069e0a260f894067d" +checksum = "721da970c312655cde9b4ffe0547f20a8494866a4af5ff51f18b7c633d0c870b" dependencies = [ "encoding_rs", "memchr", @@ -2335,9 +2351,9 @@ dependencies = [ [[package]] name = "redox_syscall" -version = "0.7.4" +version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f450ad9c3b1da563fb6948a8e0fb0fb9269711c9c73d9ea1de5058c79c8d643a" +checksum = "4666a1a60d8412eab19d94f6d13dcc9cea0a5ef4fdf6a5db306537413c661b1b" dependencies = [ "bitflags", ] @@ -2735,9 +2751,9 @@ dependencies = [ [[package]] name = "serde_with" -version = "3.18.0" +version = "3.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd5414fad8e6907dbdd5bc441a50ae8d6e26151a03b1de04d89a5576de61d01f" +checksum = "f05839ce67618e14a09b286535c0d9c94e85ef25469b0e13cb4f844e5593eb19" dependencies = [ "serde_core", "serde_with_macros", @@ -2745,9 +2761,9 @@ dependencies = [ [[package]] name = "serde_with_macros" -version = "3.18.0" +version = "3.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3db8978e608f1fe7357e211969fd9abdcae80bac1ba7a3369bb7eb6b404eb65" +checksum = "cf2ebbe86054f9b45bc3881e865683ccfaccce97b9b4cb53f3039d67f355a334" dependencies = [ "darling", "proc-macro2", @@ -3397,9 +3413,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.52.1" +version = "1.52.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b67dee974fe86fd92cc45b7a95fdd2f99a36a6d7b0d431a231178d3d670bbcc6" +checksum = "110a78583f19d5cdb2c5ccf321d1290344e71313c6c37d43520d386027d18386" dependencies = [ "bytes", "libc", @@ -3727,6 +3743,7 @@ dependencies = [ "getrandom 0.4.2", "js-sys", "rand 0.10.1", + "serde_core", "wasm-bindgen", ] diff --git a/Cargo.toml b/Cargo.toml @@ -17,7 +17,7 @@ thiserror = "2" anyhow = "1.0" serde_json = "1.0" serde = { version = "1.0", features = ["derive"] } -tokio = { version = "1.42", features = ["macros"] } +tokio = { version = "1.42", features = ["macros", "process"] } sqlx = { version = "0.8", default-features = false, features = [ "postgres", "runtime-tokio", @@ -36,8 +36,7 @@ taler-macros = { path = "../taler-rust/common/taler-macros" } libeufin-ebics = { path = "crates/libeufin-ebics" } jiff = { version = "0.2", default-features = false, features = ["tz-system"] } clap = { version = "4.5", features = ["derive"] } -uuid = { version = "1.0", features = ["v4", "fast-rng"] } -getrandom = "0.4.2" +uuid = { version = "1.0", features = ["v4", "fast-rng", "serde"] } rand = "0.10" #taler-common = { git = "git://git.taler.net/taler-rust.git/" } #taler-api = { git = "git://git.taler.net/taler-rust.git/" } diff --git a/crates/libeufin-bank/Cargo.toml b/crates/libeufin-bank/Cargo.toml @@ -25,8 +25,9 @@ serde.workspace = true sqlx.workspace = true compact_str.workspace = true uuid.workspace = true -getrandom.workspace = true axum.workspace = true +rand.workspace = true +futures = "0.3" url = "2.5" reedline = "0.47" regex = "1.12" diff --git a/crates/libeufin-bank/src/api.rs b/crates/libeufin-bank/src/api.rs @@ -22,6 +22,7 @@ use sqlx::PgPool; use crate::config::BankCfg; pub mod account; +pub mod tan; pub mod token; pub struct BankState { @@ -31,13 +32,14 @@ pub struct BankState { #[cfg(test)] pub mod test { - use std::{cell::RefCell, collections::BTreeMap, ops::Deref, sync::Arc}; + use std::{collections::BTreeMap, ops::Deref, sync::Arc}; use axum::{ Router, http::{Method, StatusCode, header::AUTHORIZATION}, }; use compact_str::CompactString; + use rand::{random_range, seq::IndexedRandom}; use sqlx::{PgPool, Postgres, pool::PoolConnection}; use taler_api::api::TalerRouter; use taler_common::{ @@ -51,7 +53,7 @@ pub mod test { use taler_test_utils::{ db::db_test_setup, json, - server::{TestRequest, TestServer as _}, + server::{TestRequest, TestResponse, TestServer as _}, }; use crate::{ @@ -59,10 +61,12 @@ pub mod test { api::{ BankState, account::{account_api, create_admin_account, rand_iban_payto}, + tan::{ChallengeResponse, tan_api}, token::token_api, }, config::BankCfg, - db::{self, account::AccountCreationResult}, + db::{self, account::CreationResult}, + mfa::TALER_CHALLENGE_IDS, payto::LibeufinId, }; @@ -75,7 +79,7 @@ pub mod test { pub admin_payto: IbanPayto, pub db: PgPool, pub server: Router, - tokens: RefCell<BTreeMap<CompactString, String>>, + tokens: BTreeMap<CompactString, String>, } impl BankTestCtx { @@ -94,76 +98,88 @@ pub mod test { } } - async fn cached_token(&self, username: &str) -> String { - if !self.tokens.borrow().contains_key(username) { - // Create new token - let res = Self::pw_auth( - self.server.post(&format!("/accounts/{username}/token")), - Some(username), - ) - .json(json!({ - "scope": "readwrite", - "duration": { - "d_us": "forever" + pub async fn cache_tokens(&mut self, usernames: &[&'static str]) { + let tasks = usernames + .into_iter() + .map(|username| { + let username = CompactString::from(*username); + async { + let res = Self::pw_auth( + self.server.post(&format!("/accounts/{username}/token")), + Some(username.as_str()), + ) + .json(json!({ + "scope": "readwrite", + "duration": { + "d_us": "forever" + } + })) + .await + .assert_ok_json::<serde_json::Value>(); + let token = res["access_token"].as_str().unwrap(); + (username, format!("Bearer {token}")) } - })) - .await - .assert_ok_json::<serde_json::Value>(); - let token = res["access_token"].as_str().unwrap(); - self.tokens - .borrow_mut() - .insert(username.into(), format!("Bearer {token}")); + }) + .collect::<Vec<_>>(); + for (username, token) in futures::future::join_all(tasks).await { + self.tokens.insert(username, token); } - self.tokens.borrow()[username].clone() } - async fn requesta( - &self, - method: Method, - path: &str, - username: Option<&str>, - ) -> TestRequest { + fn requesta(&self, method: Method, path: &str, username: Option<&str>) -> TestRequest { let username = username.unwrap_or_else(|| Self::extract_username(path)); - let token = self.cached_token(username).await; + let token = &self.tokens[username]; self.server .request(method, path) .header(AUTHORIZATION, token) } - pub async fn postpw(&self, path: &str) -> TestRequest { + pub fn postpw(&self, path: &str) -> TestRequest { Self::pw_auth(self.server.request(Method::POST, path), None) } - pub async fn geta(&self, path: &str) -> TestRequest { - self.requesta(Method::GET, path, None).await + pub fn geta(&self, path: &str) -> TestRequest { + self.requesta(Method::GET, path, None) } - pub async fn posta(&self, path: &str) -> TestRequest { - self.requesta(Method::POST, path, None).await + pub fn posta(&self, path: &str) -> TestRequest { + self.requesta(Method::POST, path, None) } - pub async fn patcha(&self, path: &str) -> TestRequest { - self.requesta(Method::PATCH, path, None).await + pub fn patcha(&self, path: &str) -> TestRequest { + self.requesta(Method::PATCH, path, None) } - pub async fn deletea(&self, path: &str) -> TestRequest { - self.requesta(Method::DELETE, path, None).await + pub fn deletea(&self, path: &str) -> TestRequest { + self.requesta(Method::DELETE, path, None) } - pub async fn get_admin(&self, path: &str) -> TestRequest { - self.requesta(Method::GET, path, Some("admin")).await + pub fn get_admin(&self, path: &str) -> TestRequest { + self.requesta(Method::GET, path, Some("admin")) } - pub async fn post_admin(&self, path: &str) -> TestRequest { - self.requesta(Method::POST, path, Some("admin")).await + pub fn post_admin(&self, path: &str) -> TestRequest { + self.requesta(Method::POST, path, Some("admin")) } - pub async fn patch_admin(&self, path: &str) -> TestRequest { - self.requesta(Method::PATCH, path, Some("admin")).await + pub fn patch_admin(&self, path: &str) -> TestRequest { + self.requesta(Method::PATCH, path, Some("admin")) } - pub async fn delete_admin(&self, path: &str) -> TestRequest { - self.requesta(Method::DELETE, path, Some("admin")).await + pub fn delete_admin(&self, path: &str) -> TestRequest { + self.requesta(Method::DELETE, path, Some("admin")) + } + + pub async fn fill_tan_info(&self, username: &str) { + self.patch_admin(&format!("/accounts/{username}")) + .json(json!({ + "contact_data": { + "phone": format!("+{}", random_range(0..10000)) + }, + "tan_channel": "sms" + })) + .await + .assert_no_content(); } } @@ -194,6 +210,7 @@ pub mod test { let server = token_api() .merge(account_api()) + .merge(tan_api()) .with_state(state.clone()) .finalize(); @@ -272,11 +289,11 @@ pub mod test { .unwrap(); let admin_payto = match res { - AccountCreationResult::Success(payto) => payto, + CreationResult::Success(payto) => payto, _ => unreachable!(), }; - BankTestCtx { + let mut ctx = BankTestCtx { merchant_payto, exchange_payto, customer_payto, @@ -285,8 +302,12 @@ pub mod test { admin_payto: Payto::new(admin_payto.into_inner().expect_iban().unwrap().clone()), server, db, - tokens: RefCell::new(BTreeMap::new()), - } + tokens: BTreeMap::new(), + }; + ctx.cache_tokens(&["admin", "merchant", "exchange", "customer"]) + .await; + + ctx } pub enum Auth { @@ -319,7 +340,6 @@ pub mod test { // Other account ctx.requesta(method.clone(), path, Some("merchant")) .await - .await .assert_error(ErrorCode::GENERIC_FORBIDDEN); } @@ -327,16 +347,86 @@ pub mod test { Auth::Admin | Auth::Exchange => { ctx.requesta(method.clone(), path, Some("merchant")) .await - .await .assert_error(ErrorCode::GENERIC_FORBIDDEN); } Auth::User => {} Auth::UserOnly => { ctx.requesta(method.clone(), path, Some("admin")) .await - .await .assert_error(ErrorCode::GENERIC_FORBIDDEN); } } } + + pub fn tan_code(info: &str) -> Option<CompactString> { + let path = format!("/tmp/tan-{}.txt", info); + let code = match std::fs::read_to_string(&path) { + Ok(f) => f, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return None, + Err(e) => Err(e).unwrap(), + }; + std::fs::remove_file(path).unwrap(); + Some(code.split(' ').next().unwrap().into()) + } + + pub trait MfaRequest { + fn assert_challenge_check( + &self, + ctx: &BankTestCtx, + check: impl FnOnce(&ChallengeResponse), + ) -> impl std::future::Future<Output = Self>; + + fn assert_challenge(&self, ctx: &BankTestCtx) -> impl std::future::Future<Output = Self> { + self.assert_challenge_check(ctx, |_| {}) + } + } + + impl MfaRequest for TestResponse { + async fn assert_challenge_check( + &self, + ctx: &BankTestCtx, + check: impl FnOnce(&ChallengeResponse), + ) -> TestResponse { + let res: ChallengeResponse = self.assert_accepted_json(); + let username = self.uri.path().split('/').nth(2).unwrap(); + + let challenges = if res.combi_and { + &res.challenges + } else { + std::slice::from_ref(res.challenges.choose(&mut rand::rng()).unwrap()) + }; + + for challenge in challenges { + ctx.posta(&format!( + "/accounts/{username}/challenge/{}", + challenge.challenge_id + )) + .await + .assert_ok(); + } + check(&res); + + for challenge in challenges { + let code = tan_code(&challenge.tan_info).unwrap(); + ctx.posta(&format!( + "/accounts/{username}/challenge/{}/confirm", + challenge.challenge_id + )) + .json(json!({ "tan": code })) + .await + .assert_no_content(); + } + // Recover body from request + let ids = res + .challenges + .into_iter() + .map(|it| it.challenge_id) + .collect::<Vec<_>>() + .join(", "); + ctx.requesta(self.method.clone(), self.uri.path(), Some(username)) + .header(TALER_CHALLENGE_IDS, ids) + .raw_json(self.request.clone()) + .await + } + } } diff --git a/crates/libeufin-bank/src/api/account.rs b/crates/libeufin-bank/src/api/account.rs @@ -17,14 +17,24 @@ * <http://www.gnu.org/licenses/> */ -use std::sync::{Arc, LazyLock}; +use std::{ + fmt::Debug, + sync::{Arc, LazyLock}, +}; -use axum::{Json, Router, extract::State, routing::post}; +use axum::{ + Json, Router, + extract::State, + http::StatusCode, + response::{IntoResponse, NoContent}, + routing::{patch, post}, +}; use compact_str::CompactString; use regex::Regex; +use serde::{Deserialize, Serialize}; use sqlx::PgPool; use taler_api::{ - error::{ApiResult, failure, failure_code}, + error::{ApiResult, failure, failure_code, failure_status}, extract::Req, }; use taler_common::{ @@ -40,17 +50,18 @@ use taler_common::{ use crate::{ TanChannel, api::BankState, - auth::RegistrationAuth, + auth::{RegistrationAuth, UserRW}, config::{BankCfg, WireMethod}, - db::account::AccountCreationResult, + db::account::{CreationResult, PatchResult, reconfig}, + mfa::{AccountReconfig, MfaReq, Tans}, payto::{FullBankPayto, LibeufinId, XTalerBank}, pw::checkpw, }; #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub struct ChallengeContactData { - pub email: Option<CompactString>, - pub phone: Option<CompactString>, + pub email: Maybe<CompactString>, + pub phone: Maybe<CompactString>, } impl ChallengeContactData { @@ -61,7 +72,7 @@ impl ChallengeContactData { static PHONE_PATTERN: LazyLock<Regex> = LazyLock::new(|| Regex::new("^\\+?[0-9]+$").unwrap()); - if let Some(email) = &self.email + if let Maybe::Some(email) = &self.email && !EMAIL_PATTERN.is_match(email) { return Err(failure( @@ -69,7 +80,7 @@ impl ChallengeContactData { format_args!("email contact data '{email}' is malformed"), )); } - if let Some(phone) = &self.phone + if let Maybe::Some(phone) = &self.phone && !PHONE_PATTERN.is_match(phone) { return Err(failure( @@ -140,34 +151,239 @@ pub struct RegisterAccountResponse { pub internal_payto_uri: FullBankPayto, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Maybe<T> { + Missing, + Null, + Some(T), +} + +impl<T> Maybe<T> { + pub fn opt(&self) -> Option<&T> { + match self { + Maybe::Missing | Maybe::Null => None, + Maybe::Some(v) => Some(v), + } + } + + pub fn is_some(&self) -> bool { + matches!(self, Maybe::Some(_)) + } + + pub fn inner(&self) -> Option<Option<&T>> { + match self { + Maybe::Missing => None, + Maybe::Null => Some(None), + Maybe::Some(v) => Some(Some(v)), + } + } +} + +impl<'de, T> Deserialize<'de> for Maybe<T> +where + T: Deserialize<'de>, +{ + fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> + where + D: serde::Deserializer<'de>, + { + let opt = Option::<Option<T>>::deserialize(deserializer)?; + Ok(match opt { + None => Maybe::Missing, + Some(None) => Maybe::Null, + Some(Some(v)) => Maybe::Some(v), + }) + } +} + +impl<T: Serialize> Serialize for Maybe<T> { + fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> { + self.opt().serialize(serializer) + } +} + +pub trait TanInfo: Debug { + fn phone(&self) -> Option<&str>; + fn email(&self) -> Option<&str>; + fn channels(&self) -> &[TanChannel]; + fn mfa(&self) -> Tans { + self.channels() + .iter() + .filter_map(|it| { + match it { + TanChannel::email => self.email(), + TanChannel::sms => self.phone(), + } + .map(|info| (*it, info.into())) + }) + .collect() + } +} + +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct AccountReconfiguration { + pub contact_data: Option<ChallengeContactData>, + pub cashout_payto_uri: Maybe<IbanPayto>, + pub name: Option<CompactString>, + pub is_public: Option<bool>, + pub debit_threshold: Option<Amount>, + pub tan_channel: Maybe<TanChannel>, + pub tan_channels: Maybe<Vec<TanChannel>>, + pub is_taler_exchange: Option<bool>, + pub conversion_rate_class_id: Maybe<u64>, +} + +impl TanInfo for AccountReconfiguration { + fn phone(&self) -> Option<&str> { + self.contact_data + .as_ref() + .and_then(|it| it.phone.opt()) + .map(|it| it.as_str()) + } + + fn email(&self) -> Option<&str> { + self.contact_data + .as_ref() + .and_then(|it| it.email.opt()) + .map(|it| it.as_str()) + } + + fn channels(&self) -> &[TanChannel] { + if let Some(many) = self.tan_channels.opt() { + many + } else if let Some(one) = self.tan_channel.opt() { + std::slice::from_ref(one) + } else { + &[] + } + } +} + +impl AccountReconfiguration { + pub fn check(&self) -> ApiResult<()> { + if self.tan_channel.is_some() && self.tan_channels.is_some() { + return Err(failure( + ErrorCode::GENERIC_JSON_INVALID, + "you must only use either tan_channel or tan_channels", + )); + } + + Ok(()) + } + + pub fn channels(&self) -> Option<&[TanChannel]> { + if let Some(many) = self.tan_channels.opt() { + Some(many) + } else if let Some(one) = self.tan_channel.opt() { + Some(std::slice::from_ref(one)) + } else { + None + } + } + + pub fn required_validation(&self, current: &impl TanInfo) -> ApiResult<Tans> { + // Tan channels are either the new ones or the current one + let channels = self.channels().unwrap_or_else(|| current.channels()); + let validated = current.mfa(); + + channels + .iter() + .filter_map(|channel| { + // Info are either the new one or the current ones + let info = match channel { + TanChannel::sms => self.phone().or(current.phone()), + TanChannel::email => self.email().or(current.email()), + }; + + let Some(info) = info else { + return Some(Err(failure( + ErrorCode::BANK_MISSING_TAN_INFO, + format_args!("missing info for tan channel {channel}"), + ))); + }; + + let tan = (*channel, info.into()); + + // Check if tan is already used and therefore already validated + (!(validated.contains(&tan))).then_some(Ok(tan)) + }) + .collect::<Result<Vec<_>, _>>() + } +} + pub fn account_api() -> Router<Arc<BankState>> { - Router::new().route( - "/accounts", - post( - async |_: RegistrationAuth, - State(state): State<Arc<BankState>>, - Req(req): Req<RegisterAccountRequest>| - -> ApiResult<Json<RegisterAccountResponse>> { - match create_account(&state.db, &state.cfg, &req, false).await? { - AccountCreationResult::BonusBalanceInsufficient => { - Err(failure_code(ErrorCode::BANK_UNALLOWED_DEBIT)) + Router::new() + .route( + "/accounts", + post( + async |_: RegistrationAuth, + State(state): State<Arc<BankState>>, + Req(req): Req<RegisterAccountRequest>| + -> ApiResult<Json<RegisterAccountResponse>> { + match create_account(&state.db, &state.cfg, &req, false).await? { + CreationResult::BonusBalanceInsufficient => { + Err(failure_code(ErrorCode::BANK_UNALLOWED_DEBIT)) + } + CreationResult::UsernameReuse => { + Err(failure_code(ErrorCode::BANK_REGISTER_USERNAME_REUSE)) + } + CreationResult::PayToReuse => { + Err(failure_code(ErrorCode::BANK_REGISTER_PAYTO_URI_REUSE)) + } + CreationResult::UnknownConversionClass => { + todo!() + } + CreationResult::Success(payto) => Ok(Json(RegisterAccountResponse { + internal_payto_uri: payto, + })), } - AccountCreationResult::UsernameReuse => { - Err(failure_code(ErrorCode::BANK_REGISTER_USERNAME_REUSE)) - } - AccountCreationResult::PayToReuse => { - Err(failure_code(ErrorCode::BANK_REGISTER_PAYTO_URI_REUSE)) + }, + ), + ) + .route( + "/accounts/{username}", + patch( + async |State(state): State<Arc<BankState>>, + MfaReq { mut auth, req, ctx }: MfaReq<UserRW, AccountReconfig>| { + if let Some(tans) = ctx.pending_mfa() { + return ctx.response_validation(&auth, &state.db, tans).await; } - AccountCreationResult::UnknownConversionClass => { - todo!() + match patch_account( + &state.db, + &state.cfg, + &req, + &auth.username, + auth.is_admin(), + ctx.is_2fa(), + ) + .await? + { + PatchResult::Success => Ok(NoContent.into_response()), + PatchResult::Challenges(tans) => { + if tans.is_empty() { + ctx.response_mfa(&mut auth, &state.db, &state.cfg.ctx).await + } else { + ctx.response_validation(&auth, &state.db, &tans).await + } + } + PatchResult::UnknownAccount => todo!(), + PatchResult::NonAdminName => { + Err(failure_code(ErrorCode::BANK_NON_ADMIN_PATCH_LEGAL_NAME)) + } + PatchResult::NonAdminCashout => { + Err(failure_code(ErrorCode::BANK_NON_ADMIN_PATCH_CASHOUT)) + } + PatchResult::NonAdminDebtLimit => { + Err(failure_code(ErrorCode::BANK_NON_ADMIN_PATCH_DEBT_LIMIT)) + } + PatchResult::NonAdminConversionRateClass => Err(failure_code( + ErrorCode::BANK_NON_ADMIN_SET_CONVERSION_RATE_CLASS, + )), + PatchResult::UnknownConversionClass => todo!(), } - AccountCreationResult::Success(payto) => Ok(Json(RegisterAccountResponse { - internal_payto_uri: payto, - })), - } - }, - ), - ) + }, + ), + ) } pub fn rand_iban_payto() -> IbanPayto { @@ -180,7 +396,7 @@ pub async fn create_account( cfg: &BankCfg, req: &RegisterAccountRequest, is_admin: bool, -) -> ApiResult<AccountCreationResult> { +) -> ApiResult<CreationResult> { req.validate()?; if matches!(req.username.as_str(), "admin" | "bank") { @@ -234,8 +450,14 @@ pub async fn create_account( &req.username, &req.password, &req.name, - req.contact_data.as_ref().and_then(|it| it.email.as_deref()), - req.contact_data.as_ref().and_then(|it| it.phone.as_deref()), + req.contact_data + .as_ref() + .and_then(|it| it.email.opt()) + .map(|it| it.as_str()), + req.contact_data + .as_ref() + .and_then(|it| it.phone.opt()) + .map(|it| it.as_str()), req.cashout_payto_uri.as_ref(), payto, req.is_public, @@ -263,7 +485,7 @@ pub async fn create_account( loop { let payto = rand_iban_payto(); let res = create(LibeufinId::IBAN(payto.into_inner())).await?; - if res == AccountCreationResult::PayToReuse && retry > 0 { + if res == CreationResult::PayToReuse && retry > 0 { retry -= 1; continue; } @@ -302,7 +524,7 @@ pub async fn create_admin_account( db: &PgPool, cfg: &BankCfg, pw: Option<&str>, -) -> anyhow::Result<AccountCreationResult> { +) -> anyhow::Result<CreationResult> { // TODO is this secure enough ? let pw = pw .map(|it| it.to_owned()) @@ -338,18 +560,75 @@ pub async fn create_admin_account( .await?) } +pub async fn patch_account( + db: &PgPool, + cfg: &BankCfg, + req: &AccountReconfiguration, + username: &str, + is_admin: bool, + is2fa: bool, +) -> ApiResult<PatchResult> { + // TODO check regional currency + // + if username == "admin" && req.is_public == Some(true) { + return Err(failure_status( + ErrorCode::END, + "'admin' account cannot be public", + StatusCode::CONFLICT, + )); + } + + if username == "exchange" && req.is_taler_exchange == Some(false) { + return Err(failure_status( + ErrorCode::END, + "'exchange' account must be a taler exchange account", + StatusCode::CONFLICT, + )); + } + + if let Some(channels) = req.channels() { + for channel in channels { + if !cfg.tan_channels.contains_key(channel) { + return Err(failure( + ErrorCode::BANK_TAN_CHANNEL_NOT_SUPPORTED, + format_args!("unsupported tan channel {channel}"), + )); + } + } + } + + reconfig( + db, + &cfg.regional_currency, + username, + req, + is_admin, + is2fa, + cfg.allow_edit_name, + cfg.allow_edit_cashout, + ) + .await +} + #[cfg(test)] pub mod test { - use taler_common::{error_code::ErrorCode, types::payto::FullPayto}; + use axum::http::StatusCode; + use serde::Serialize; + use taler_common::{ + error_code::ErrorCode, + types::payto::{BankID, FullPayto, IbanPayto}, + }; use taler_test_utils::{json, server::TestServer as _}; use crate::{ + TanChannel, api::{ account::{RegisterAccountResponse, rand_iban_payto}, - test::bank_setup_conf, + test::{BankTestCtx, bank_setup_conf}, }, - payto::LibeufinId, + config::BankCfg, + payto::{LibeufinId, PaytoCtx}, }; #[tokio::test] @@ -441,4 +720,82 @@ pub mod test { .assert_error(ErrorCode::BANK_NON_ADMIN_PATCH_DEBT_LIMIT); // TODO check ok admin } + + async fn check_admin_only(ctx: &BankTestCtx, req: impl Serialize, error: ErrorCode) { + // Check restricted + ctx.patcha("/accounts/merchant") + .json(&req) + .await + .assert_error(error); + // Check admin always can + ctx.patch_admin("/accounts/merchant") + .json(&req) + .await + .assert_no_content(); + // Check idempotent + ctx.patch_admin("/accounts/merchant") + .json(&req) + .await + .assert_no_content(); + } + + #[tokio::test] + async fn reconfig() { + let ctx = bank_setup_conf("test.conf").await; + + for channel in TanChannel::entries { + ctx.patcha("/accounts/merchant") + .json(json!({ "tan_channel": channel })) + .await + .assert_error(ErrorCode::BANK_MISSING_TAN_INFO); + ctx.patcha("/accounts/merchant") + .json(json!({ "tan_channels": [channel] })) + .await + .assert_error(ErrorCode::BANK_MISSING_TAN_INFO); + } + ctx.patcha("/accounts/merchant") + .json(json!({ "tan_channels": TanChannel::entries })) + .await + .assert_error(ErrorCode::BANK_MISSING_TAN_INFO); + + // Successful attempt now + let cashout = rand_iban_payto(); + let req = json!({ + "cashout_payto_uri": cashout, + "name": "Roger", + "is_public": true, + "contact_data": { + "phone": "+99", + "email": "foo@example.com" + } + }); + ctx.patcha("/accounts/merchant") + .json(&req) + .await + .assert_no_content(); + // Checking idempotent + ctx.patcha("/accounts/merchant") + .json(&req) + .await + .assert_no_content(); + + // Check patch + // TODO check set taler exchange + + // Admin cannot be public + ctx.patcha("/accounts/admin") + .json(json!({ + "is_public": true + })) + .await + .assert_error_status(ErrorCode::END, StatusCode::CONFLICT); + + // Exchange must be exchange + ctx.patcha("/accounts/exchange") + .json(json!({ + "is_taler_exchange": false + })) + .await + .assert_error_status(ErrorCode::END, StatusCode::CONFLICT); + } } diff --git a/crates/libeufin-bank/src/api/tan.rs b/crates/libeufin-bank/src/api/tan.rs @@ -0,0 +1,488 @@ +/* +* 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/> +*/ + +use std::{sync::Arc, time::Duration}; + +use axum::{ + Json, Router, + extract::State, + http::StatusCode, + response::{IntoResponse, NoContent, Response}, + routing::post, +}; +use compact_str::CompactString; +use jiff::Timestamp; +use serde::{Deserialize, Serialize}; +use taler_api::{ + error::{ApiResult, failure, failure_code}, + extract::{Path, Req}, +}; +use taler_common::{error_code::ErrorCode, types::timestamp::TalerTimestamp}; +use tokio::{io::AsyncWriteExt as _, process::Command}; +use uuid::Uuid; + +use crate::{ + TanChannel, + api::BankState, + db::tan::{SendResult, SolveResult, mark_sent, send, solve}, +}; + +pub const MAX_ACTIVE_CHALLENGES: u16 = 5; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ChallengeResponse { + pub challenges: Vec<Challenge>, + pub combi_and: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Challenge { + pub challenge_id: String, + pub tan_channel: TanChannel, + pub tan_info: CompactString, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ChallengeRequestResponse { + pub solve_expiration: TalerTimestamp, + pub earliest_retransmission: TalerTimestamp, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ChallengeSolve { + pub tan: CompactString, +} + +#[axum::debug_handler] +pub async fn tmp( + State(state): State<Arc<BankState>>, + Path((_, id)): Path<(CompactString, Uuid)>, +) -> ApiResult<Response> { + match send(&state.db, &id, &Timestamp::now(), MAX_ACTIVE_CHALLENGES).await? { + SendResult::NotFound => Err(failure_code(ErrorCode::BANK_TRANSACTION_NOT_FOUND)), + SendResult::Expired => Err(failure_code(ErrorCode::BANK_TRANSACTION_NOT_FOUND)), + SendResult::TooMany => Err(failure_code(ErrorCode::BANK_TAN_RATE_LIMITED)), + SendResult::Solved => Ok(StatusCode::GONE.into_response()), + SendResult::Send { + info, + channel, + code, + expiration, + } => { + let (script, env) = &state.cfg.tan_channels[&channel]; + let msg = format!("T-{code} is your {} verification code", state.cfg.name); + let res = async { + let mut child = Command::new(script) + .arg(&info) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .envs(env.iter()) + .spawn()?; + + if let Some(mut stdin) = child.stdin.take() { + let _ = stdin.write_all(msg.as_bytes()).await; + } + + child.wait_with_output().await + } + .await; + let output = match res { + Err(e) => { + tracing::error!(target: "tan", "{channel} {script} failed: {e}"); + return Err(failure( + ErrorCode::BANK_TAN_CHANNEL_SCRIPT_FAILED, + format_args!("TAN channel {channel} IO failure"), + )); + } + Ok(output) => output, + }; + + let code = output.status.code().unwrap_or(-1); + if code != 0 { + let out = String::from_utf8_lossy(&output.stdout); + tracing::error!(target: "tan", "{channel} {script}: {code} {out}"); + return Err(failure( + ErrorCode::BANK_TAN_CHANNEL_SCRIPT_FAILED, + format_args!("TAN channel {channel} failure with exit code"), + )); + } + + let retransmission = Timestamp::now() + Duration::from_mins(3); + mark_sent(&state.db, &id, &retransmission).await?; + Ok(Json(ChallengeRequestResponse { + solve_expiration: expiration.into(), + earliest_retransmission: retransmission.into(), + }) + .into_response()) + } + SendResult::Success { + expiration, + retransmission, + } => Ok(Json(ChallengeRequestResponse { + solve_expiration: expiration.into(), + earliest_retransmission: retransmission.into(), + }) + .into_response()), + } +} + +pub fn tan_api() -> Router<Arc<BankState>> { + Router::new().route( + "/accounts/{username}/challenge/{id}", + post( + async |State(state): State<Arc<BankState>>, + Path((_, id)): Path<(CompactString, Uuid)>| { + match send(&state.db, &id, &Timestamp::now(), MAX_ACTIVE_CHALLENGES).await? { + SendResult::NotFound => { + Err(failure_code(ErrorCode::BANK_TRANSACTION_NOT_FOUND)) + } + SendResult::Expired => Err(failure_code(ErrorCode::BANK_TRANSACTION_NOT_FOUND)), + SendResult::TooMany => Err(failure_code(ErrorCode::BANK_TAN_RATE_LIMITED)), + SendResult::Solved => Ok(StatusCode::GONE.into_response()), + SendResult::Send { + info, + channel, + code, + expiration, + } => { + let (script, env) = &state.cfg.tan_channels[&channel]; + let msg = format!("T-{code} is your {} verification code", state.cfg.name); + let res = async { + let mut child = Command::new(script) + .arg(&info) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .envs(env.iter()) + .spawn()?; + + if let Some(mut stdin) = child.stdin.take() { + let _ = stdin.write_all(msg.as_bytes()).await; + } + + child.wait_with_output().await + } + .await; + let output = match res { + Err(e) => { + tracing::error!(target: "tan", "{channel} {script} failed: {e}"); + return Err(failure( + ErrorCode::BANK_TAN_CHANNEL_SCRIPT_FAILED, + format_args!("TAN channel {channel} IO failure"), + )); + } + Ok(output) => output, + }; + + let code = output.status.code().unwrap_or(-1); + if code != 0 { + let out = String::from_utf8_lossy(&output.stdout); + tracing::error!(target: "tan", "{channel} {script}: {code} {out}"); + return Err(failure( + ErrorCode::BANK_TAN_CHANNEL_SCRIPT_FAILED, + format_args!("TAN channel {channel} failure with exit code"), + )); + } + + let retransmission = Timestamp::now() + Duration::from_mins(3); + mark_sent(&state.db, &id, &retransmission).await?; + Ok(Json(ChallengeRequestResponse { + solve_expiration: expiration.into(), + earliest_retransmission: retransmission.into(), + }) + .into_response()) + } + SendResult::Success { + expiration, + retransmission, + } => Ok(Json(ChallengeRequestResponse { + solve_expiration: expiration.into(), + earliest_retransmission: retransmission.into(), + }) + .into_response()), + } + }, + ), + ).route( + "/accounts/{username}/challenge/{id}/confirm", + post( + async |State(state): State<Arc<BankState>>, + Path((_, id)): Path<(CompactString, Uuid)>, Req(req): Req<ChallengeSolve>| { + let code = req.tan.strip_prefix("T-").unwrap_or(&req.tan); + match solve(&state.db, &id, code, &Timestamp::now()).await? { + SolveResult::NotFound => Err(failure_code(ErrorCode::BANK_TRANSACTION_NOT_FOUND)), + SolveResult::BadCode => Err(failure_code(ErrorCode::BANK_TAN_CHALLENGE_FAILED)), + SolveResult::NoRetry => Err(failure_code(ErrorCode::BANK_TAN_RATE_LIMITED)), + SolveResult::Expired => Err(failure_code(ErrorCode::BANK_TAN_CHALLENGE_EXPIRED)), + SolveResult::Success { .. } => { + Ok(NoContent) + } + } + }, + ), + ) +} + +#[cfg(test)] +pub mod test { + + use taler_common::error_code::ErrorCode; + use taler_test_utils::{json, server::TestResponse}; + use uuid::Uuid; + + use crate::{ + TanChannel, + api::{ + tan::{Challenge, ChallengeResponse}, + test::{MfaRequest, bank_setup_conf, tan_code}, + }, + mfa::TALER_CHALLENGE_IDS, + }; + + #[tokio::test] + async fn send() { + let ctx = bank_setup_conf("test.conf").await; + + let expect_mfa = async |res: TestResponse, and: bool, tans: &[(TanChannel, &str)]| { + res.assert_challenge_check(&ctx, |res| { + assert_eq!( + tans, + res.challenges + .iter() + .map(|it| (it.tan_channel, it.tan_info.as_str())) + .collect::<Vec<_>>() + ); + assert_eq!(res.combi_and, and); + }) + .await + }; + + let send = async |c: &Challenge| { + ctx.posta(&format!("/accounts/merchant/challenge/{}", c.challenge_id)) + .await + .assert_ok(); + }; + + macro_rules! patch { + ($($json:tt)+) => { + ctx.patcha("/accounts/merchant").json(json!($($json)+)).await + }; + } + + // Set up 2fa + expect_mfa( + patch!({ + "contact_data": { + "phone": "+99", + "email": "email@example.com" + }, + "tan_channel": "sms" + }), + true, + &[(TanChannel::sms, "+99")], + ) + .await + .assert_no_content(); + + // Update 2fa settings - first 2FA challenge then new tan channel check + expect_mfa( + patch!({ // Info change + "contact_data": { "phone": "+98" }, + }), + true, + &[(TanChannel::sms, "+99"), (TanChannel::sms, "+98")], + ) + .await + .assert_no_content(); + expect_mfa( + patch!({ // Channel change + "tan_channel": "email" + }), + true, + &[ + (TanChannel::sms, "+98"), + (TanChannel::email, "email@example.com"), + ], + ) + .await + .assert_no_content(); + expect_mfa( + patch!({ // Both change + "contact_data": { "phone": "+97" }, + "tan_channel": "sms" + }), + true, + &[ + (TanChannel::email, "email@example.com"), + (TanChannel::sms, "+97"), + ], + ) + .await + .assert_no_content(); + + // Disable 2fa + expect_mfa( + patch!({ // Both change + "tan_channel": () + }), + true, + &[(TanChannel::sms, "+97")], + ) + .await + .assert_no_content(); + + // Update mfa settings - first mfa challenge then new tan channel check + expect_mfa( + patch!({ // Both change + "tan_channels": ["sms", "email"] + }), + true, + &[ + (TanChannel::sms, "+97"), + (TanChannel::email, "email@example.com"), + ], + ) + .await + .assert_no_content(); + expect_mfa( + expect_mfa( + patch!({ + "contact_data": { + "phone": "+99", + "email": "email2@example.com" + } + }), + false, + &[ + (TanChannel::sms, "+97"), + (TanChannel::email, "email@example.com"), + ], + ) + .await, + true, + &[ + (TanChannel::sms, "+99"), + (TanChannel::email, "email2@example.com"), + ], + ) + .await + .assert_no_content(); + + expect_mfa( + patch!({ + "contact_data": { + "phone": "+99", + "email": "email2@example.com" + } + }), + false, + &[ + (TanChannel::sms, "+99"), + (TanChannel::email, "email2@example.com"), + ], + ) + .await + .assert_no_content(); + + // Admin has no 2FA + ctx.patch_admin("/accounts/merchant") + .json(json!({ + "contact_data": { "phone": "+99" }, + "tan_channel": "sms" + })) + .await + .assert_no_content(); + ctx.patch_admin("/accounts/merchant") + .json(json!({ + "tan_channel": "email" + })) + .await + .assert_no_content(); + ctx.patch_admin("/accounts/merchant") + .json(json!({ + "tan_channel": () + })) + .await + .assert_no_content(); + + // Check retry and invalidate + patch!({ + "contact_data": { "phone": "+88" }, + "tan_channel": "sms" + }) + .assert_challenge(&ctx) + .await + .assert_no_content(); + let res: ChallengeResponse = ctx + .patcha("/accounts/merchant") + .json(json!({ + "is_public": false + })) + .await + .assert_accepted_json(); + let challenge = &res.challenges[0]; + // Check ok + send(challenge).await; + let code = tan_code("+88").unwrap(); + // Check retry + send(challenge).await; + assert!(tan_code("+88").is_none()); + // Idempotent patch does nothing + patch!({ + "contact_data": { "phone": "+88" }, + "tan_channel": "sms" + }) + .assert_accepted(); + send(challenge).await; + assert!(tan_code("+88").is_none()); + // Change 2fa settings + patch!({ + "tan_channel": "email" + }) + .assert_challenge(&ctx) + .await + .assert_no_content(); + // Check invalidated + ctx.posta(&format!( + "/accounts/merchant/challenge/{}/confirm", + challenge.challenge_id, + )) + .json(json!({"tan": code})) + .await + .assert_error(ErrorCode::BANK_TAN_CHALLENGE_EXPIRED); + ctx.patcha("/accounts/merchant") + .header(TALER_CHALLENGE_IDS, challenge.challenge_id.to_string()) + .json(json!({"is_public": false})) + .await + .assert_challenge(&ctx) + .await + .assert_no_content(); + + // Unknown challenge + ctx.posta(&format!("/accounts/merchant/challenge/{}", Uuid::new_v4())) + .await + .assert_error(ErrorCode::BANK_TRANSACTION_NOT_FOUND); + + // Unknown challenge + ctx.posta("/accounts/merchant/challenge/BAD") + .await + .assert_error(ErrorCode::GENERIC_PATH_SEGMENT_MALFORMED); + } +} diff --git a/crates/libeufin-bank/src/api/token.rs b/crates/libeufin-bank/src/api/token.rs @@ -88,12 +88,10 @@ pub fn token_api() -> Router<Arc<BankState>> { .route( "/accounts/{username}/token", post( - async |Auth { - username, token, .. - }: Auth<Token>, + async |auth: Auth<Token>, State(state): State<Arc<BankState>>, Req(req): Req<TokenRequest>| { - if let Some(token) = token { + if let Some(token) = auth.token { // This block checks permissions ONLY IF the call was authenticated with a token let token = access(&state.db, &token, &Timestamp::now()).await?; let Some(token) = token else { @@ -128,7 +126,7 @@ pub fn token_api() -> Router<Arc<BankState>> { }; match create( &state.db, - &username, + &auth.username, new.as_ref(), &creation, &expiration, @@ -257,7 +255,6 @@ pub mod test { // Default token duration let res: TokenSuccessResponse = ctx .postpw("/accounts/merchant/token") - .await .json(json!({ "scope": "readonly" })) .await .assert_ok_json(); @@ -291,7 +288,6 @@ pub mod test { ] { let res: TokenSuccessResponse = ctx .postpw("/accounts/merchant/token") - .await .json(json!({ "scope": from_scope, "refreshable": true })) .await .assert_ok_json(); @@ -310,7 +306,6 @@ pub mod test { ] { let res: TokenSuccessResponse = ctx .postpw("/accounts/merchant/token") - .await .json(json!({ "scope": from_scope, "refreshable": true })) .await .assert_ok_json(); @@ -324,7 +319,6 @@ pub mod test { // Check no refreshable let res: TokenSuccessResponse = ctx .postpw("/accounts/merchant/token") - .await .json(json!({ "scope": "readonly" })) .await .assert_ok_json(); @@ -337,7 +331,6 @@ pub mod test { // Check 'forever' case let res: TokenSuccessResponse = ctx .postpw("/accounts/merchant/token") - .await .json(json!({ "scope": "readonly", "duration": { @@ -350,7 +343,6 @@ pub mod test { // Check too big or invalid durations ctx.postpw("/accounts/merchant/token") - .await .json(json!({ "scope": "readonly", "duration": { @@ -361,7 +353,6 @@ pub mod test { .assert_error(ErrorCode::GENERIC_JSON_INVALID); ctx.postpw("/accounts/merchant/token") - .await .json(json!({ "scope": "readonly", "duration": { @@ -371,7 +362,6 @@ pub mod test { .await .assert_error(ErrorCode::GENERIC_JSON_INVALID); ctx.postpw("/accounts/merchant/token") - .await .json(json!({ "scope": "readonly", "duration": { @@ -384,7 +374,6 @@ pub mod test { // Delete current token let res: TokenSuccessResponse = ctx .postpw("/accounts/merchant/token") - .await .json(json!({ "scope": "readonly" })) .await .assert_ok_json(); @@ -404,23 +393,19 @@ pub mod test { // Delete by id let res: TokenSuccessResponse = ctx .postpw("/accounts/merchant/token") - .await .json(json!({ "scope": "readonly" })) .await .assert_ok_json(); // Wrong account ctx.deletea(&format!("/accounts/customer/tokens/{}", res.token_id)) .await - .await .assert_error(ErrorCode::BANK_TRANSACTION_NOT_FOUND); // Check OK ctx.deletea(&format!("/accounts/merchant/tokens/{}", res.token_id)) .await - .await .assert_no_content(); ctx.deletea(&format!("/accounts/merchant/tokens/{}", res.token_id)) .await - .await .assert_error(ErrorCode::BANK_TRANSACTION_NOT_FOUND); // Check token no longer work ctx.post("/accounts/merchant/token") @@ -446,38 +431,27 @@ pub mod test { for account in ["merchant", "customer"] { ctx.geta(&format!("/accounts/{account}/tokens")) .await - .await .assert_no_content(); } for scope in ["readonly", "readwrite"] { ctx.postpw("/accounts/merchant/token") - .await .json(json!({ "scope": scope })) .await .assert_ok(); } ctx.postpw("/accounts/customer/token") - .await .json(json!({ "scope": "revenue", "description": "description" })) .await .assert_ok(); - let res: TokenInfos = ctx - .geta("/accounts/merchant/tokens") - .await - .await - .assert_ok_json(); + let res: TokenInfos = ctx.geta("/accounts/merchant/tokens").await.assert_ok_json(); assert_eq!(res.tokens.len(), 2); for token in res.tokens { assert_eq!(token.description, None); } - let res: TokenInfos = ctx - .geta("/accounts/customer/tokens") - .await - .await - .assert_ok_json(); + let res: TokenInfos = ctx.geta("/accounts/customer/tokens").await.assert_ok_json(); assert_eq!(res.tokens.len(), 1); assert_eq!(res.tokens[0].description.as_deref(), Some("description")); } diff --git a/crates/libeufin-bank/src/auth.rs b/crates/libeufin-bank/src/auth.rs @@ -31,7 +31,9 @@ use compact_str::CompactString; use jiff::Timestamp; use serde::{Deserialize, Serialize}; use sqlx::PgPool; -use taler_api::error::{ApiError, ApiResult, failure, failure_code, failure_status, unauthorized}; +use taler_api::error::{ + ApiError, ApiResult, failure, failure_code, failure_status, forbidden, unauthorized, +}; use taler_common::{ encoding::{base32, base64}, error_code::ErrorCode, @@ -45,7 +47,7 @@ use crate::{ account::{BankInfo, CheckPasswordResult, check_password}, token::access_info, }, - payto::BankCtx, + payto::PaytoCtx, pw::PwCrypto, }; @@ -136,10 +138,7 @@ impl FromRequestParts<Arc<BankState>> for RegistrationAuth { ) .await?; if !info.is_admin() { - return Err(failure( - ErrorCode::GENERIC_FORBIDDEN, - "Only administrator allowed", - )); + return Err(forbidden("Only administrator allowed")); } Ok(RegistrationAuth) } @@ -151,19 +150,43 @@ pub enum AuthKind { UserOrAdmin, } -pub trait AuthScope { +pub trait AuthScope: Send { const SCOPE: TokenLogicalScope; const KIND: AuthKind; const ALLOW_BASIC_AUTH: bool = false; } -pub struct Auth<S: AuthScope> { +pub struct Auth<S> { pub username: CompactString, - pub info: BankInfo, pub token: Option<Vec<u8>>, + auth_info: BankInfo, + user_info: Option<BankInfo>, scope: PhantomData<S>, } +impl<S> Auth<S> { + /** Retrieve the bank account info for the selected username */ + pub async fn bank_info(&mut self, db: &PgPool, ctx: &PaytoCtx) -> ApiResult<&BankInfo> { + if self.user_info.is_none() { + if self.auth_info.username == self.username { + return Ok(&self.auth_info); + } + let info = super::db::account::bank_info(db, ctx, &self.username).await?; + + let Some(info) = info else { + todo!("Unknown account") + }; + self.user_info = Some(info); + } + Ok(self.user_info.as_ref().unwrap()) + } + + /** Check if authenticated user is admin */ + pub fn is_admin(&self) -> bool { + self.auth_info.is_admin() + } +} + fn extract_username(url: &Uri) -> &str { let mut iter = url.path().strip_prefix('/').unwrap().split('/'); assert_eq!(iter.next(), Some("accounts")); @@ -192,39 +215,31 @@ impl<S: AuthScope> FromRequestParts<Arc<BankState>> for Auth<S> { match S::KIND { AuthKind::AdminOnly => { if !info.is_admin() { - return Err(failure( - ErrorCode::GENERIC_FORBIDDEN, - "Only administrator allowed", - )); + return Err(forbidden("Only administrator allowed")); } } AuthKind::UserOnly => { if info.username != username { - return Err(failure( - ErrorCode::GENERIC_FORBIDDEN, - format_args!( - "Customer {} have no right on {username} account", - info.username - ), - )); + return Err(forbidden(format_args!( + "Customer {} have no right on {username} account", + info.username + ))); } } AuthKind::UserOrAdmin => { if info.username != username && !info.is_admin() { - return Err(failure( - ErrorCode::GENERIC_FORBIDDEN, - format_args!( - "Customer {} have no right on {username} account", - info.username - ), - )); + return Err(forbidden(format_args!( + "Customer {} have no right on {username} account", + info.username + ))); } } } Ok(Self { username: username.into(), - info, + auth_info: info, + user_info: None, token, scope: PhantomData, }) @@ -262,7 +277,7 @@ impl AuthScope for Token { */ async fn auth_request( db: &PgPool, - ctx: &BankCtx, + ctx: &PaytoCtx, pw_crypto: &PwCrypto, scope: TokenLogicalScope, allow_pw: bool, diff --git a/crates/libeufin-bank/src/config.rs b/crates/libeufin-bank/src/config.rs @@ -31,7 +31,7 @@ use taler_macros::EnumMeta; use tracing::warn; use url::Url; -use crate::{TanChannel, payto::BankCtx, pw::PwCrypto}; +use crate::{TanChannel, payto::PaytoCtx, pw::PwCrypto}; pub fn parse_db_cfg(cfg: &Config) -> Result<DbCfg, ValueErr> { DbCfg::parse(cfg.section("libeufin-bankdb-postgres")) @@ -74,7 +74,7 @@ pub struct BankCfg { pub fiat_currency: Option<(Currency, CurrencySpecification)>, pub spa_path: Option<String>, pub tan_channels: BTreeMap<TanChannel, (String, BTreeMap<CompactString, CompactString>)>, - pub ctx: BankCtx, + pub ctx: PaytoCtx, pub wire_method: WireMethod, pub pw_crypto: PwCrypto, pub gc_abort_after: Span, @@ -172,7 +172,7 @@ impl BankCfg { fiat_currency: fiat, spa_path: s.path("spa").opt()?, tan_channels, - ctx: BankCtx { + ctx: PaytoCtx { bic: s.parse("bic", "iban_payto_bic").opt()?, hostname, }, diff --git a/crates/libeufin-bank/src/db.rs b/crates/libeufin-bank/src/db.rs @@ -20,4 +20,6 @@ const SCHEMA: &str = "libeufin_bank"; pub mod account; + +pub mod tan; pub mod token; diff --git a/crates/libeufin-bank/src/db/account.rs b/crates/libeufin-bank/src/db/account.rs @@ -16,15 +16,20 @@ use compact_str::CompactString; use jiff::Timestamp; -use sqlx::{PgPool, Row as _, postgres::PgRow}; -use taler_api::db::{BindHelper as _, PgError, TypeHelper as _}; +use sqlx::{PgPool, QueryBuilder, Row as _, postgres::PgRow}; +use taler_api::{ + db::{BindHelper as _, PgError, TypeHelper as _}, + error::ApiResult, +}; use taler_common::types::{ - amount::Amount, + amount::{Amount, Currency}, payto::{BankID, IbanPayto}, }; use crate::{ - BankCtx, TanChannel, + PaytoCtx, TanChannel, + api::account::{AccountReconfiguration, TanInfo}, + mfa::Tans, payto::{FullBankPayto, LibeufinId, sql_bank_payto}, pw::PwCrypto, }; @@ -32,7 +37,7 @@ use crate::{ const MAX_TOKEN_CREATION_ATTEMPTS: u16 = 5; #[derive(Debug, Clone, PartialEq, Eq)] -pub enum AccountCreationResult { +pub enum CreationResult { Success(FullBankPayto), UsernameReuse, PayToReuse, @@ -43,7 +48,7 @@ pub enum AccountCreationResult { /** Create new account */ pub async fn create( db: &PgPool, - ctx: &BankCtx, + ctx: &PaytoCtx, pw_crypto: &PwCrypto, username: &str, password: &str, @@ -59,7 +64,7 @@ pub async fn create( tan_channels: &[TanChannel], check_payto_idempotent: bool, conversion_rate_class_id: Option<u64>, -) -> sqlx::Result<AccountCreationResult> { +) -> sqlx::Result<CreationResult> { // TODO serialized let mut tx = db.begin().await?; let now = Timestamp::now(); @@ -106,9 +111,9 @@ pub async fn create( .await?; let res = if let Some((matches, payto)) = idempotent { if matches { - AccountCreationResult::Success(payto) + CreationResult::Success(payto) } else { - AccountCreationResult::UsernameReuse + CreationResult::UsernameReuse } } else { if let LibeufinId::IBAN(BankID { iban, .. }) = &internal { @@ -121,7 +126,7 @@ pub async fn create( && e.is_unique_err() { tx.rollback().await?; - return sqlx::Result::Ok(AccountCreationResult::PayToReuse); + return sqlx::Result::Ok(CreationResult::PayToReuse); } res?; } @@ -175,12 +180,12 @@ pub async fn create( && e.is_unique_err() { tx.rollback().await?; - return sqlx::Result::Ok(AccountCreationResult::PayToReuse); + return sqlx::Result::Ok(CreationResult::PayToReuse); } else if let Err(e) = &res && e.is_fk_err() { tx.rollback().await?; - return sqlx::Result::Ok(AccountCreationResult::PayToReuse); + return sqlx::Result::Ok(CreationResult::PayToReuse); } res?; @@ -191,30 +196,271 @@ pub async fn create( ").bind(&canonical).bind(bonus).bind(now.as_microsecond()).fetch_one(&mut *tx).await?; if insufisient { tx.rollback().await?; - return sqlx::Result::Ok(AccountCreationResult::BonusBalanceInsufficient); + return sqlx::Result::Ok(CreationResult::BonusBalanceInsufficient); } } - AccountCreationResult::Success(internal.bank(name, ctx)) + CreationResult::Success(internal.bank(name, ctx)) }; tx.commit().await?; sqlx::Result::Ok(res) } -pub struct BankInfo { - pub username: CompactString, - pub payto: FullBankPayto, - pub bank_account_id: u64, - pub is_exchange: bool, - pub phone: Option<CompactString>, - pub email: Option<CompactString>, - pub channels: Vec<TanChannel>, +/** Result status of account deletion */ +pub enum AccountDeletionResult { + Success, + UnknownAccount, + BalanceNotZero, + TanRequired, } -impl BankInfo { - pub fn is_admin(&self) -> bool { - self.username == "admin" +/** Delete account [username] */ +pub async fn delete( + db: &sqlx::PgPool, + username: &str, + is2fa: bool, +) -> sqlx::Result<AccountDeletionResult> { + sqlx::query( + " + SELECT + out_not_found, + out_balance_not_zero, + out_tan_required + FROM account_delete($1,$2,$3) + ", + ) + .bind(username) + .bind_timestamp(&Timestamp::now()) + .bind(is2fa) + .try_map(|r: PgRow| { + Ok(if r.try_get_flag("out_not_found")? { + AccountDeletionResult::UnknownAccount + } else if r.try_get_flag("out_balance_not_zero")? { + AccountDeletionResult::BalanceNotZero + } else if r.try_get_flag("out_tan_required")? { + AccountDeletionResult::TanRequired + } else { + AccountDeletionResult::Success + }) + }) + .fetch_one(db) + .await +} + +/** Result status of customer account patch */ +pub enum PatchResult { + UnknownAccount, + NonAdminName, + NonAdminCashout, + NonAdminDebtLimit, + NonAdminConversionRateClass, + UnknownConversionClass, + Challenges(Tans), + Success, +} + +/** Change account [username] information */ +pub async fn reconfig( + db: &PgPool, + currency: &Currency, + username: &str, + req: &AccountReconfiguration, + is_admin: bool, + is2fa: bool, + allow_edit_name: bool, + allow_edit_cashout: bool, +) -> ApiResult<PatchResult> { + let AccountReconfiguration { + contact_data, + cashout_payto_uri, + name, + is_public, + debit_threshold, + tan_channel, + tan_channels, + is_taler_exchange, + conversion_rate_class_id, + } = req; + + let mut tx = db.begin().await?; + + #[derive(Debug)] + struct CurrentAccount { + id: u64, + channels: Vec<TanChannel>, + email: Option<CompactString>, + phone: Option<CompactString>, + name: String, + cashout_pay_to: Option<IbanPayto>, + debt_limit: Amount, + conversion_rate_class_id: Option<u64>, + } + + impl TanInfo for CurrentAccount { + fn email(&self) -> Option<&str> { + self.email.as_deref() + } + + fn phone(&self) -> Option<&str> { + self.phone.as_deref() + } + + fn channels(&self) -> &[TanChannel] { + &self.channels + } + } + + // Get user ID and current data + let curr = sqlx::query( + " + SELECT + customer_id, + tan_channels, + phone, + email, + name, + cashout_payto, + max_debt, + conversion_rate_class_id + FROM customers + JOIN bank_accounts + ON customer_id=owning_customer_id + WHERE username=$1 AND deleted_at IS NULL + ", + ) + .bind(username) + .try_map(|r: PgRow| { + Ok(CurrentAccount { + id: r.try_get_u64("customer_id")?, + channels: r.try_get("tan_channels")?, + email: r.try_get("email")?, + phone: r.try_get("phone")?, + name: r.try_get("name")?, + cashout_pay_to: r.try_get_opt_parse("cashout_payto")?, + debt_limit: r.try_get_amount("max_debt", currency)?, + conversion_rate_class_id: r.try_get_opt_u64("conversion_rate_class_id")?, + }) + }) + .fetch_optional(&mut *tx) + .await?; + let Some(curr) = curr else { + return Ok(PatchResult::UnknownAccount); + }; + + let validation = req.required_validation(&curr)?; + + // Check performed 2fa check + if !is_admin && !is2fa { + // Check if mfa is required + if !curr.channels.is_empty() { + let mut tans = curr.mfa(); + + if tans.len() == 1 { + // Performs mfa and validation at the same time + tans.extend(validation); + return Ok(PatchResult::Challenges(tans)); + } else { + return Ok(PatchResult::Challenges(Vec::new())); + } + } + + // Check if validation is required + if !validation.is_empty() { + return Ok(PatchResult::Challenges(validation)); + } + } + + // Check reconfig rights + if !is_admin { + if !allow_edit_name + && let Some(name) = name + && name != curr.name + { + return Ok(PatchResult::NonAdminName); + } else if !allow_edit_cashout + && let Some(cashout) = cashout_payto_uri.inner() + && cashout != curr.cashout_pay_to.as_ref() + { + return Ok(PatchResult::NonAdminCashout); + } else if let Some(limit) = debit_threshold + && limit != &curr.debt_limit + { + return Ok(PatchResult::NonAdminDebtLimit); + } else if let Some(id) = conversion_rate_class_id.inner() + && id != curr.conversion_rate_class_id.as_ref() + { + return Ok(PatchResult::NonAdminConversionRateClass); + } + } + + // Update bank info + let mut sql = QueryBuilder::new("Update bank_accounts SET "); + let mut separated = sql.separated(','); + if let Some(v) = is_public { + separated.push("is_public=").push_bind_unseparated(v); + } + if let Some(v) = debit_threshold { + separated.push("max_debt=").push_bind_unseparated(v); + } + if let Some(v) = conversion_rate_class_id.inner() { + separated + .push("conversion_rate_class_id=") + .push_bind_unseparated(v.map(|it| *it as i64)); + } + if !sql.sql().ends_with("SET ") { + sql.push(" WHERE owning_customer_id=") + .push_bind(curr.id as i64); + let res = sql.build().execute(&mut *tx).await; + if let Err(e) = &res + && e.is_fk_err() + { + tx.rollback().await?; + return Ok(PatchResult::UnknownConversionClass); + } + res?; + } + + // Update customer info + let mut sql = QueryBuilder::new("UPDATE customers SET "); + let mut separated = sql.separated(','); + if let Some(v) = cashout_payto_uri.inner() { + separated + .push("cashout_payto=") + .push_bind_unseparated(v.map(|it| it.as_payto().to_string())); + } + if let Some(v) = req.contact_data.as_ref().and_then(|it| it.phone.inner()) { + separated.push("phone=").push_bind_unseparated(v); + } + if let Some(v) = req.contact_data.as_ref().and_then(|it| it.email.inner()) { + separated.push("email=").push_bind_unseparated(v); } + if let Some(v) = req.channels() { + separated + .push("tan_channels=sort_uniq(") + .push_bind_unseparated(v) + .push_unseparated(')'); + } + if let Some(v) = &req.name { + separated.push("name=").push_bind_unseparated(v); + } + if !sql.sql().ends_with("SET ") { + sql.push(" WHERE customer_id=") + .push_bind(curr.id as i64) + .build() + .execute(&mut *tx) + .await?; + } + + if !validation.is_empty() { + sqlx::query("UPDATE tan_challenges SET expiration_date=0 WHERE customer=$1") + .bind(curr.id as i64) + .execute(&mut *tx) + .await?; + } + + tx.commit().await?; + + Ok(PatchResult::Success) } /** Result status of customer account password check */ @@ -227,7 +473,7 @@ pub enum CheckPasswordResult { pub async fn check_password( db: &PgPool, - ctx: &BankCtx, + ctx: &PaytoCtx, pw_crypto: &PwCrypto, username: &str, pw: &str, @@ -296,3 +542,71 @@ pub async fn check_password( } Ok(CheckPasswordResult::Success(info)) } + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BankInfo { + pub username: CompactString, + pub payto: FullBankPayto, + pub bank_account_id: u64, + pub is_exchange: bool, + pub phone: Option<CompactString>, + pub email: Option<CompactString>, + pub channels: Vec<TanChannel>, +} + +impl TanInfo for BankInfo { + fn phone(&self) -> Option<&str> { + self.phone.as_deref() + } + + fn email(&self) -> Option<&str> { + self.email.as_deref() + } + + fn channels(&self) -> &[TanChannel] { + &self.channels + } +} + +impl BankInfo { + pub fn is_admin(&self) -> bool { + self.username == "admin" + } +} + +/** Get bank info of account [username] */ +pub async fn bank_info( + db: &PgPool, + ctx: &PaytoCtx, + username: &str, +) -> sqlx::Result<Option<BankInfo>> { + sqlx::query( + " + SELECT + bank_account_id, + internal_payto, + name, + is_taler_exchange, + tan_channels, + email, + phone + FROM bank_accounts + JOIN customers ON customer_id=owning_customer_id + WHERE username=$1 + ", + ) + .bind(username) + .try_map(|r: PgRow| { + Ok(BankInfo { + username: username.into(), + payto: sql_bank_payto(&r, ctx, "internal_payto", "name")?, + bank_account_id: r.try_get_u64("bank_account_id")?, + is_exchange: r.try_get("is_taler_exchange")?, + phone: r.try_get("phone")?, + email: r.try_get("email")?, + channels: r.try_get("tan_channels")?, + }) + }) + .fetch_optional(db) + .await +} diff --git a/crates/libeufin-bank/src/db/tan.rs b/crates/libeufin-bank/src/db/tan.rs @@ -0,0 +1,261 @@ +/* +* 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 tan challenged + +use std::time::Duration; + +use compact_str::CompactString; +use jiff::Timestamp; +use sqlx::{PgPool, Row, postgres::PgRow}; +use taler_api::db::{BindHelper, TypeHelper}; +use taler_common::types::base32::Base32; +use uuid::Uuid; + +use crate::{TanChannel, mfa::Operation}; + +/** Create a new challenge */ +pub async fn new( + db: &PgPool, + username: &str, + op: Operation, + hash: &Base32<64>, + salt: &Base32<16>, + code: &str, + now: &Timestamp, + retry_counter: u16, + validity: Duration, + channel: TanChannel, + info: &str, +) -> sqlx::Result<Uuid> { + sqlx::query_scalar( + " + INSERT INTO tan_challenges ( + hbody, + salt, + op, + code, + creation_date, + expiration_date, + retry_counter, + customer, + tan_channel, + tan_info, + uuid + ) VALUES ( + $1, + $2, + $3, + $4, + $5, + $6, + $7, + (SELECT customer_id FROM customers WHERE username = $8 AND deleted_at IS NULL), + $9, + $10, + gen_random_uuid() + ) RETURNING uuid + ", + ) + .bind(hash) + .bind(salt) + .bind(op) + .bind(code) + .bind_timestamp(now) + .bind_timestamp(&(*now + validity)) + .bind(retry_counter as i16) + .bind(username) + .bind(channel) + .bind(info) + .fetch_one(db) + .await +} + +/** Result of TAN challenge transmission */ +pub enum SendResult { + Send { + info: CompactString, + channel: TanChannel, + code: CompactString, + expiration: Timestamp, + }, + Success { + expiration: Timestamp, + retransmission: Timestamp, + }, + Expired, + Solved, + NotFound, + TooMany, +} + +/** Request TAN challenge transmission */ +pub async fn send( + db: &PgPool, + uuid: &Uuid, + now: &Timestamp, + max_active: u16, +) -> sqlx::Result<SendResult> { + Ok(sqlx::query(" + SELECT + (confirmation_date IS NOT NULL) as solved + ,retransmission_date + ,expiration_date + ,code + ,tan_channel + ,tan_info + -- If this is the first time we submit this challenge check there is not too many active challenges + ,(retransmission_date = 0 AND ( + SELECT count(*) >= $1 + FROM tan_challenges as o + WHERE c.customer = o.customer + AND retransmission_date != 0 + AND confirmation_date IS NULL + AND expiration_date >= $2 + )) AS too_many + FROM tan_challenges as c + WHERE uuid = $3 + ") + .bind(max_active as i16) + .bind_timestamp(now) + .bind(uuid) + .try_map(|r: PgRow| Ok( + if r.try_get("solved")? { + SendResult::Solved + } else if r.try_get("too_many")? { + SendResult::TooMany + } else { + let retransmission = r.try_get_timestamp ("retransmission_date")?; + let expiration = r.try_get_timestamp("expiration_date")?; + if expiration < *now { + SendResult::Expired + } else if retransmission < *now { + SendResult::Send { + info: r.try_get("tan_info")?, + channel: r.try_get("tan_channel")?, + code: r.try_get("code")?, + expiration + } + } else { + SendResult::Success { + expiration, + retransmission + } + } + } + )) + .fetch_optional(db).await?.unwrap_or(SendResult::NotFound)) +} + +/** Mark TAN challenge transmission */ +pub async fn mark_sent(db: &PgPool, uuid: &Uuid, retransmission: &Timestamp) -> sqlx::Result<()> { + sqlx::query("UPDATE tan_challenges SET retransmission_date = $1 WHERE uuid = $2") + .bind_timestamp(retransmission) + .bind(uuid) + .execute(db) + .await?; + Ok(()) +} + +/** Result of TAN challenge solution */ +pub enum SolveResult { + Success { + op: Operation, + channel: Option<TanChannel>, + info: Option<CompactString>, + }, + NotFound, + NoRetry, + Expired, + BadCode, +} + +/** Solve TAN challenge */ +pub async fn solve( + db: &PgPool, + uuid: &Uuid, + code: &str, + timestamp: &Timestamp, +) -> sqlx::Result<SolveResult> { + sqlx::query( + " + SELECT + out_ok, out_no_op, out_no_retry, out_expired, + out_op, out_channel, out_info + FROM tan_challenge_try($1,$2,$3) + ", + ) + .bind(uuid) + .bind(code) + .bind_timestamp(timestamp) + .try_map(|r: PgRow| { + Ok(if r.try_get_flag("out_ok")? { + SolveResult::Success { + op: r.try_get("out_op")?, + channel: r.try_get("out_channel")?, + info: r.try_get("out_info")?, + } + } else if r.try_get_flag("out_no_op")? { + SolveResult::NotFound + } else if r.try_get_flag("out_no_retry")? { + SolveResult::NoRetry + } else if r.try_get_flag("out_expired")? { + SolveResult::Expired + } else { + SolveResult::BadCode + }) + }) + .fetch_one(db) + .await +} + +#[derive(Debug)] +pub struct SolvedChallenge { + pub id: Uuid, + pub salt: Base32<16>, + pub hash: Base32<64>, + pub channel: TanChannel, + pub info: CompactString, + pub confirmed: bool, + pub op: Operation, +} + +pub async fn challenge(db: &PgPool, uuids: &[Uuid]) -> sqlx::Result<Vec<SolvedChallenge>> { + sqlx::query( + " + SELECT uuid, salt, hbody, tan_channel, tan_info, op, (confirmation_date IS NOT NULL) as confirmed + FROM tan_challenges + WHERE uuid = ANY($1) + ", + ) + .bind(uuids) + .try_map(|r: PgRow| { + Ok(SolvedChallenge { + id: r.try_get("uuid")?, + salt: r.try_get("salt")?, + hash: r.try_get("hbody")?, + channel: r.try_get("tan_channel")?, + info: r.try_get("tan_info")?, + confirmed: r.try_get("confirmed")?, + op: r.try_get("op")?, + }) + }) + .fetch_all(db) + .await +} diff --git a/crates/libeufin-bank/src/db/token.rs b/crates/libeufin-bank/src/db/token.rs @@ -32,7 +32,7 @@ use crate::{ api::token::TokenInfo, auth::TokenScope, db::account::BankInfo, - payto::{BankCtx, sql_bank_payto}, + payto::{PaytoCtx, sql_bank_payto}, }; pub struct BearerToken { @@ -120,7 +120,7 @@ pub async fn access( /** Get info for [token] and its associated bank account*/ pub async fn access_info( db: &PgPool, - ctx: &BankCtx, + ctx: &PaytoCtx, token: &[u8], access_time: &Timestamp, ) -> sqlx::Result<Option<(BearerToken, BankInfo)>> { diff --git a/crates/libeufin-bank/src/lib.rs b/crates/libeufin-bank/src/lib.rs @@ -21,12 +21,13 @@ use serde::{Deserialize, Serialize}; use taler_common::config::parser::ConfigSource; use taler_macros::EnumMeta; -use crate::payto::BankCtx; +use crate::payto::PaytoCtx; pub mod api; pub mod auth; pub mod config; pub mod db; +pub mod mfa; pub mod payto; pub mod pw; @@ -35,17 +36,7 @@ pub const CONFIG_SOURCE: ConfigSource = // Allowed values for cashout TAN channels. #[derive( - sqlx::Type, - Debug, - Clone, - Copy, - PartialEq, - Eq, - PartialOrd, - Ord, - EnumMeta, - Serialize, - Deserialize, + sqlx::Type, Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, EnumMeta, Serialize, Deserialize, )] #[sqlx(type_name = "tan_enum")] #[enum_meta(Str)] diff --git a/crates/libeufin-bank/src/mfa.rs b/crates/libeufin-bank/src/mfa.rs @@ -0,0 +1,312 @@ +/* +* 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/> +*/ + +use std::{marker::PhantomData, str::FromStr as _, sync::Arc, time::Duration}; + +use aws_lc_rs::digest::SHA512; +use axum::{ + Json, + body::Bytes, + extract::{FromRequest, FromRequestParts, Request}, + http::{HeaderName, StatusCode}, + response::IntoResponse, +}; +use compact_str::CompactString; +use jiff::Timestamp; +use rand::random_range; +use serde::{Deserialize, Serialize, de::DeserializeOwned}; +use sqlx::PgPool; +use taler_api::{ + error::{ApiError, ApiResult, failure, forbidden}, + extract::{Req, decompressed_strict_body}, +}; +use taler_common::{error_code::ErrorCode, types::base32::Base32}; +use taler_macros::EnumMeta; +use uuid::Uuid; + +use crate::{ + TanChannel, + api::{ + BankState, + account::{AccountReconfiguration, TanInfo}, + tan::{Challenge, ChallengeResponse}, + }, + auth::{Auth, AuthScope}, + db::account::BankInfo, + payto::PaytoCtx, +}; + +#[derive( + sqlx::Type, Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, EnumMeta, Serialize, Deserialize, +)] +#[sqlx(type_name = "op_enum")] +#[enum_meta(Str)] +#[allow(non_camel_case_types)] +pub enum Operation { + account_reconfig, + account_delete, + account_auth_reconfig, + bank_transaction, + cashout, + withdrawal, + create_token, +} + +pub type Tans = Vec<(TanChannel, CompactString)>; + +pub trait MfaOperation { + const OP: Operation; + type Body: DeserializeOwned + Send; + + fn required_validation(_: &Self::Body, _: &BankInfo) -> ApiResult<Option<Tans>> { + Ok(None) + } +} + +pub struct AccountReconfig; + +impl MfaOperation for AccountReconfig { + const OP: Operation = Operation::account_reconfig; + + type Body = AccountReconfiguration; + + fn required_validation(body: &Self::Body, info: &BankInfo) -> ApiResult<Option<Tans>> { + Ok(Some(body.required_validation(info)?)) + } +} + +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()); + digest.update(body); + Base32::try_from(digest.finish().as_ref()).unwrap() +} + +#[derive(Debug)] +enum Mfa { + None, + Challenged, + Pending(Tans), +} + +#[derive(Debug)] +pub struct MfaCtx<O: MfaOperation> { + raw: Bytes, + mfa: Mfa, + op: PhantomData<O>, +} + +impl<O: MfaOperation> MfaCtx<O> { + async fn respond_challenges( + &self, + db: &PgPool, + username: &str, + tans: &[(TanChannel, CompactString)], + ) -> ApiResult<Vec<Challenge>> { + const TAN_RETRY_COUNTER: u16 = 3; + const TAN_VALIDITY_PERIOD: Duration = Duration::from_mins(30); + + let salt = Base32::secure_rand(); + let hash = mfa_body_hash(&self.raw, &salt); + let mut challenges = Vec::new(); + + for (channel, info) in tans { + let code = gen_tan_code(); + let uuid = super::db::tan::new( + db, + username, + O::OP, + &hash, + &salt, + &code, + &Timestamp::now(), + TAN_RETRY_COUNTER, + TAN_VALIDITY_PERIOD, + *channel, + info, + ) + .await?; + // Create token is a public challenge + let info = if O::OP == Operation::create_token { + CompactString::const_new("REDACTED") + } else { + info.clone() + }; + challenges.push(Challenge { + challenge_id: uuid.to_string(), + tan_channel: *channel, + tan_info: info, + }) + } + Ok(challenges) + } + + /** + * Generate a TAN challenge for an [op] request with [body] and + * respond to the HTTP request with a TAN challenge. + * + * If [channel] and [info] are present, they will be used + * to send the TAN code, otherwise defaults will be used. + */ + pub async fn response_mfa<S>( + &self, + auth: &mut Auth<S>, + db: &PgPool, + ctx: &PaytoCtx, + ) -> ApiResult<axum::response::Response> { + let info = auth.bank_info(db, ctx).await?; + let challenges = self + .respond_challenges(db, &info.username, &info.mfa()) + .await?; + Ok(( + StatusCode::ACCEPTED, + Json(ChallengeResponse { + challenges, + combi_and: false, + }), + ) + .into_response()) + } + + pub async fn response_validation<S>( + &self, + auth: &Auth<S>, + db: &PgPool, + tans: &[(TanChannel, CompactString)], + ) -> ApiResult<axum::response::Response> { + let challenges = self.respond_challenges(db, &auth.username, tans).await?; + Ok(( + StatusCode::ACCEPTED, + Json(ChallengeResponse { + challenges, + combi_and: true, + }), + ) + .into_response()) + } + + pub fn pending_mfa(&self) -> Option<&[(TanChannel, CompactString)]> { + match &self.mfa { + Mfa::None => None, + Mfa::Challenged => None, + Mfa::Pending(items) => Some(items), + } + } + + pub fn is_2fa(&self) -> bool { + matches!(self.mfa, Mfa::Challenged) + } +} + +pub const TALER_CHALLENGE_IDS: HeaderName = HeaderName::from_static("taler-challenge-ids"); + +#[must_use] +pub struct MfaReq<S, O: MfaOperation> { + pub auth: Auth<S>, + pub req: O::Body, + pub ctx: MfaCtx<O>, +} + +impl<S: AuthScope, O: MfaOperation> FromRequest<Arc<BankState>> for MfaReq<S, O> { + type Rejection = ApiError; + + async fn from_request(req: Request, state: &Arc<BankState>) -> Result<Self, Self::Rejection> { + let (mut parts, body) = req.into_parts(); + let mut auth = Auth::from_request_parts(&mut parts, state).await?; + let raw = 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) { + Some(header) => { + let uuids: Option<Vec<Uuid>> = header.to_str().ok().and_then(|s| { + s.split(',') + .map(|s| Uuid::from_str(s.trim()).ok()) + .collect() + }); + let Some(uuids) = uuids else { + return Err(failure( + ErrorCode::GENERIC_HTTP_HEADERS_MALFORMED, + format_args!("{TALER_CHALLENGE_IDS} does not contains valid challenge ids"), + ) + .with_path(TALER_CHALLENGE_IDS)); + }; + let challenges = super::db::tan::challenge(&state.db, &uuids).await?; + let mut validated = Vec::new(); + for challenge in challenges { + if challenge.op != O::OP { + return Err(forbidden(format_args!( + "Challenge '{}' is for a different operation", + challenge.id + ))); + } else if mfa_body_hash(&raw, &challenge.salt) != challenge.hash { + return Err(forbidden(format_args!( + "Challenge '{}' is for a different request", + challenge.id + ))); + } else if challenge.confirmed { + validated.push((challenge.channel, challenge.info)); + } + } + + if !validated.is_empty() { + // Check if challenges are solved + let info = auth.bank_info(&state.db, &state.cfg.ctx).await?; + + if let Some(validation) = O::required_validation(&req, info)? { + // Check mfa & new TAN validation + if validation.iter().all(|it| validated.contains(it)) { + Mfa::Challenged + } else if info.mfa().iter().any(|it| validated.contains(it)) { + Mfa::Pending(validation) + } else { + Mfa::None + } + } else { + // Check mfa + if info.mfa().iter().any(|it| validated.contains(it)) { + Mfa::Challenged + } else { + Mfa::None + } + } + } else { + Mfa::None + } + } + None => Mfa::None, + }; + Ok(Self { + auth, + req, + ctx: MfaCtx { + raw, + mfa, + op: PhantomData, + }, + }) + } +} + +/// Generate a secure random TAN code +pub fn gen_tan_code() -> String { + // TODO do we need a more secure rng here ? + // Generate a random number between 0 and 99,999,999 + let rand_val: u32 = random_range(0..100000000); + format!("{:08}", rand_val) +} diff --git a/crates/libeufin-bank/src/payto.rs b/crates/libeufin-bank/src/payto.rs @@ -91,7 +91,7 @@ impl LibeufinId { } } - pub fn bank(mut self, name: &str, ctx: &BankCtx) -> FullBankPayto { + pub fn bank(mut self, name: &str, ctx: &PaytoCtx) -> FullBankPayto { match &mut self { LibeufinId::IBAN(bank_id) => bank_id.bic = ctx.bic.clone(), LibeufinId::XTalerBank(xtaler_bank) => xtaler_bank.hostname = ctx.hostname.clone(), @@ -139,14 +139,14 @@ impl PaytoImpl for LibeufinId { } } -pub struct BankCtx { +pub struct PaytoCtx { pub bic: Option<BIC>, pub hostname: CompactString, } pub fn sql_bank_payto( r: &PgRow, - ctx: &BankCtx, + ctx: &PaytoCtx, payto_idx: &str, name_idx: &str, ) -> sqlx::Result<FullBankPayto> { diff --git a/crates/libeufin-bank/src/pw.rs b/crates/libeufin-bank/src/pw.rs @@ -1,5 +1,6 @@ use anyhow::anyhow; use aws_lc_rs::digest::SHA256; +use rand::{TryRng as _, rngs::SysRng}; use taler_api::error::{ApiResult, failure}; use taler_common::{encoding::base64, error_code::ErrorCode}; @@ -50,7 +51,7 @@ impl PwCrypto { match self { PwCrypto::Bcrypt { cost } => { let mut salt = [0u8; 16]; - getrandom::fill(&mut salt).unwrap(); + SysRng.try_fill_bytes(&mut salt).unwrap(); let pwh = bcrypt::bcrypt(*cost, salt, pw.as_bytes()); format!("bcrypt${cost}${}${}", base64::fmt(salt), base64::fmt(pwh)) } diff --git a/crates/libeufin-ebics/Cargo.toml b/crates/libeufin-ebics/Cargo.toml @@ -26,7 +26,6 @@ tokio.workspace = true jiff.workspace = true clap.workspace = true uuid.workspace = true -getrandom.workspace = true rand.workspace = true tempfile = "3" flate2 = { version = "1.0", features = ["zlib-rs"], default-features = false } diff --git a/crates/libeufin-ebics/src/crypto.rs b/crates/libeufin-ebics/src/crypto.rs @@ -33,6 +33,7 @@ use aws_lc_rs::{ signature::{RSA_PSS_2048_8192_SHA256, RSA_PSS_SHA256, UnparsedPublicKey}, }; use jiff::{Timestamp, Zoned, tz::TimeZone}; +use rand::{TryRng, rngs::SysRng}; use rcgen::{BasicConstraints, CertificateParams, DnType, IsCa, KeyUsagePurpose}; use taler_common::encoding::{base64, hex}; use x509_parser::prelude::{FromDer as _, X509Certificate}; @@ -122,7 +123,7 @@ pub fn ebics_pub_key_hash(public_key: &PublicKey) -> Digest { pub fn gen_ebics_e002_key(pub_key: PublicEncryptingKey) -> ([u8; 16], Vec<u8>) { let mut transaction_key = [0u8; 16]; - getrandom::fill(&mut transaction_key).unwrap(); + SysRng.try_fill_bytes(&mut transaction_key).unwrap(); let key = Pkcs1PublicEncryptingKey::new(pub_key).unwrap(); let mut encrypted_key = vec![0; key.ciphertext_size()]; diff --git a/crates/libeufin-nexus/src/lib.rs b/crates/libeufin-nexus/src/lib.rs @@ -395,12 +395,12 @@ pub async fn run(cfg: Config, cmd: Cmd) -> anyhow::Result<()> { .subject .as_ref() .or(subject.as_ref()) - .ok_or(anyhow!("Mising subject"))?; + .ok_or(anyhow!("Missing subject"))?; let amount = payto .amount .as_ref() .or(amount.as_ref()) - .ok_or(anyhow!("Mising amount"))?; + .ok_or(anyhow!("Missing amount"))?; if cfg.currency != amount.currency { bail!( diff --git a/crates/libeufin-nexus/src/testing.rs b/crates/libeufin-nexus/src/testing.rs @@ -142,12 +142,12 @@ impl TestingCmd { .subject .as_ref() .or(subject.as_ref()) - .ok_or(anyhow!("Mising subject"))?; + .ok_or(anyhow!("Missing subject"))?; let amount = payto .amount .as_ref() .or(amount.as_ref()) - .ok_or(anyhow!("Mising amount"))?; + .ok_or(anyhow!("Missing amount"))?; if cfg.currency != amount.currency { bail!(