commit 133fdbb79b37bd8b27adec6c43bc15c0359ef2d4
parent 8d9b269ef687600804e1649e829c6e785b213311
Author: Antoine A <>
Date: Tue, 5 May 2026 18:05:29 +0200
bank: more TAN logic
Diffstat:
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);
+ }
+ }
}