commit 8d9b269ef687600804e1649e829c6e785b213311
parent 8398693a23f3774478221940f2ad683da4512b90
Author: Antoine A <>
Date: Tue, 5 May 2026 14:43:58 +0200
bank: add TAN logic
Diffstat:
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!(