libeufin

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

commit 133fdbb79b37bd8b27adec6c43bc15c0359ef2d4
parent 8d9b269ef687600804e1649e829c6e785b213311
Author: Antoine A <>
Date:   Tue,  5 May 2026 18:05:29 +0200

bank: more TAN logic

Diffstat:
Mcrates/libeufin-bank/src/api/tan.rs | 245++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------------
1 file changed, 175 insertions(+), 70 deletions(-)

diff --git a/crates/libeufin-bank/src/api/tan.rs b/crates/libeufin-bank/src/api/tan.rs @@ -150,10 +150,8 @@ pub fn tan_api() -> Router<Arc<BankState>> { 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::NotFound => Err(failure_code(ErrorCode::BANK_TRANSACTION_NOT_FOUND)), + SendResult::Expired => Err(failure_code(ErrorCode::BANK_TAN_CHALLENGE_EXPIRED)), SendResult::TooMany => Err(failure_code(ErrorCode::BANK_TAN_RATE_LIMITED)), SendResult::Solved => Ok(StatusCode::GONE.into_response()), SendResult::Send { @@ -223,18 +221,19 @@ pub fn tan_api() -> Router<Arc<BankState>> { ).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) - } - } + 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_CHALLENGE_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) + } }, ), ) @@ -250,7 +249,7 @@ pub mod test { use crate::{ TanChannel, api::{ - tan::{Challenge, ChallengeResponse}, + tan::{Challenge, ChallengeRequestResponse, ChallengeResponse}, test::{MfaRequest, bank_setup_conf, tan_code}, }, mfa::TALER_CHALLENGE_IDS, @@ -385,13 +384,9 @@ pub mod test { .await .assert_no_content(); + // Disable mfa expect_mfa( - patch!({ - "contact_data": { - "phone": "+99", - "email": "email2@example.com" - } - }), + patch!({ "tan_channels": [] }), false, &[ (TanChannel::sms, "+99"), @@ -423,58 +418,62 @@ pub mod test { .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})) + { + 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 @@ -485,4 +484,110 @@ pub mod test { .await .assert_error(ErrorCode::GENERIC_PATH_SEGMENT_MALFORMED); } + + #[tokio::test] + async fn rate_limited() { + let ctx = bank_setup_conf("test.conf").await; + ctx.fill_tan_info("merchant").await; + + // TODO need transaction API + } + + #[tokio::test] + async fn tan_err() { + let ctx = bank_setup_conf("test_tan_err.conf").await; + ctx.fill_tan_info("merchant").await; + let res: ChallengeResponse = ctx + .patcha("/accounts/merchant") + .json(json!({ + "is_public": false + })) + .await + .assert_accepted_json(); + let challenge = &res.challenges[0]; + ctx.posta(&format!( + "/accounts/merchant/challenge/{}", + challenge.challenge_id + )) + .await + .assert_error(ErrorCode::BANK_TAN_CHANNEL_SCRIPT_FAILED); + } + + #[tokio::test] + async fn confirm() { + let ctx = bank_setup_conf("test.conf").await; + ctx.fill_tan_info("merchant").await; + + // Check simple case + { + let res: ChallengeResponse = ctx + .patcha("/accounts/merchant") + .json(json!({ "is_public": false })) + .await + .assert_accepted_json(); + let challenge = &res.challenges[0]; + let id = &challenge.challenge_id; + ctx.posta(&format!("/accounts/merchant/challenge/{id}")) + .await + .assert_ok_json::<ChallengeRequestResponse>(); + let code = tan_code(&challenge.tan_info); + + // Check bad TAN code + ctx.posta(&format!("/accounts/merchant/challenge/{id}/confirm")) + .json(json!({ "tan": "nice-try" })) + .await + .assert_error(ErrorCode::BANK_TAN_CHALLENGE_FAILED); + + // Check wrong account + ctx.posta(&format!("/accounts/customer/challenge/{id}/confirm")) + .json(json!({ "tan": "nice-try" })) + .await + .assert_error(ErrorCode::BANK_TAN_CHALLENGE_FAILED); + + // Check OK + ctx.posta(&format!("/accounts/customer/challenge/{id}/confirm")) + .json(json!({ "tan": code })) + .await + .assert_no_content(); + // Check idempotence + ctx.posta(&format!("/accounts/customer/challenge/{id}/confirm")) + .json(json!({ "tan": code })) + .await + .assert_no_content(); + + // Unknown challenge + ctx.posta(&format!( + "/accounts/customer/challenge/{}/confirm", + Uuid::new_v4() + )) + .json(json!({ "tan": code })) + .await + .assert_error(ErrorCode::BANK_CHALLENGE_NOT_FOUND); + } + + // Check invalidation + { + let res: ChallengeResponse = ctx + .patcha("/accounts/merchant") + .json(json!({ "is_public": false })) + .await + .assert_accepted_json(); + let challenge = &res.challenges[0]; + let id = &challenge.challenge_id; + ctx.posta(&format!("/accounts/merchant/challenge/{id}")) + .await + .assert_ok_json::<ChallengeRequestResponse>(); + + // Check invalidated + ctx.fill_tan_info("merchant").await; + ctx.posta(&format!("/accounts/customer/challenge/{id}/confirm")) + .json(json!({ "tan": tan_code(&challenge.tan_info) })) + .await + .assert_error(ErrorCode::BANK_TAN_CHALLENGE_EXPIRED); + + ctx.posta(&format!("/accounts/customer/challenge/{id}")) + .await + .assert_error(ErrorCode::BANK_TAN_CHALLENGE_EXPIRED); + } + } }