libeufin

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

tan.rs (20142B)


      1 /*
      2 * This file is part of LibEuFin.
      3 * Copyright (C) 2026 Taler Systems S.A.
      4 
      5 * LibEuFin is free software; you can redistribute it and/or modify
      6 * it under the terms of the GNU Affero General Public License as
      7 * published by the Free Software Foundation; either version 3, or
      8 * (at your option) any later version.
      9 
     10 * LibEuFin is distributed in the hope that it will be useful, but
     11 * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
     12 * or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU Affero General
     13 * Public License for more details.
     14 
     15 * You should have received a copy of the GNU Affero General Public
     16 * License along with LibEuFin; see the file COPYING.  If not, see
     17 * <http://www.gnu.org/licenses/>
     18 */
     19 
     20 use std::{sync::Arc, time::Duration};
     21 
     22 use axum::{
     23     Json, Router,
     24     extract::State,
     25     http::StatusCode,
     26     response::{IntoResponse, NoContent},
     27     routing::post,
     28 };
     29 use compact_str::CompactString;
     30 use jiff::Timestamp;
     31 use serde::{Deserialize, Serialize};
     32 use taler_api::{
     33     error::{failure, failure_code},
     34     extract::{Path, Req},
     35 };
     36 use taler_common::{error_code::ErrorCode, types::time::TalerTimestamp};
     37 use tokio::{io::AsyncWriteExt as _, process::Command};
     38 use tracing::trace;
     39 use uuid::Uuid;
     40 
     41 use crate::{
     42     TanChannel,
     43     api::BankState,
     44     db::tan::{SendResult, SolveResult, mark_sent, send, solve},
     45 };
     46 
     47 pub const MAX_ACTIVE_CHALLENGES: u16 = 5;
     48 
     49 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
     50 pub struct ChallengeResponse {
     51     pub challenges: Vec<Challenge>,
     52     pub combi_and: bool,
     53 }
     54 
     55 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
     56 pub struct Challenge {
     57     pub challenge_id: String,
     58     pub tan_channel: TanChannel,
     59     pub tan_info: CompactString,
     60 }
     61 
     62 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
     63 pub struct ChallengeRequestResponse {
     64     pub solve_expiration: TalerTimestamp,
     65     pub earliest_retransmission: TalerTimestamp,
     66 }
     67 
     68 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
     69 pub struct ChallengeSolve {
     70     pub tan: CompactString,
     71 }
     72 
     73 pub fn tan_api() -> Router<Arc<BankState>> {
     74     Router::new().route(
     75         "/accounts/{username}/challenge/{id}",
     76         post(
     77             async |State(state): State<Arc<BankState>>,
     78                    Path((_, id)): Path<((), Uuid)>| {
     79                 match send(&state.db, &id, &Timestamp::now(), MAX_ACTIVE_CHALLENGES).await? {
     80                     SendResult::NotFound => Err(failure_code(ErrorCode::BANK_CHALLENGE_NOT_FOUND)),
     81                     SendResult::Expired => Err(failure_code(ErrorCode::BANK_TAN_CHALLENGE_EXPIRED)),
     82                     SendResult::TooMany => Err(failure_code(ErrorCode::BANK_TAN_RATE_LIMITED)),
     83                     SendResult::Solved => Ok(StatusCode::GONE.into_response()),
     84                     SendResult::Send {
     85                         info,
     86                         channel,
     87                         code,
     88                         expiration,
     89                     } => {
     90                         let Some((script, env) )= &state.cfg.tan_channels.get(&channel) else {
     91                             return Err(failure_code(ErrorCode::BANK_TAN_CHANNEL_NOT_SUPPORTED))
     92                         };
     93                         let msg = format!("T-{code} is your {} verification code", state.cfg.name);
     94                         trace!(target: "tan", "send {code} with {script}");
     95                         let res = async {
     96                             let mut child = Command::new(script)
     97                                 .arg(&info)
     98                                 .stdin(std::process::Stdio::piped())
     99                                 .stdout(std::process::Stdio::piped())
    100                                 .stderr(std::process::Stdio::piped())
    101                                 .envs(env.iter())
    102                                 .spawn()?;
    103 
    104                             if let Some(mut stdin) = child.stdin.take() {
    105                                 let _ = stdin.write_all(msg.as_bytes()).await;
    106                             }
    107 
    108                             child.wait_with_output().await
    109                         }
    110                         .await;
    111                         let output = match res {
    112                             Err(e) => {
    113                                 tracing::error!(target: "tan", "{channel} {script} failed: {e}");
    114                                 return Err(failure(
    115                                     ErrorCode::BANK_TAN_CHANNEL_SCRIPT_FAILED,
    116                                     format_args!("TAN channel {channel} IO failure"),
    117                                 ));
    118                             }
    119                             Ok(output) => output,
    120                         };
    121 
    122                         let code = output.status.code().unwrap_or(-1);
    123                         state.metrics.register_tan_result(channel, code);
    124                         if code != 0 {
    125                             let out = String::from_utf8_lossy(&output.stdout);
    126                             tracing::error!(target: "tan", "{channel} {script}: {code} {out}");
    127                             return Err(failure(
    128                                 ErrorCode::BANK_TAN_CHANNEL_SCRIPT_FAILED,
    129                                 format_args!("TAN channel {channel} failure with exit code"),
    130                             ));
    131                         }
    132 
    133                         let retransmission = Timestamp::now() + Duration::from_mins(3);
    134                         mark_sent(&state.db, &id, &retransmission).await?;
    135                         Ok(Json(ChallengeRequestResponse {
    136                             solve_expiration: expiration.into(),
    137                             earliest_retransmission: retransmission.into(),
    138                         })
    139                         .into_response())
    140                     }
    141                     SendResult::Success {
    142                         expiration,
    143                         retransmission,
    144                     } => Ok(Json(ChallengeRequestResponse {
    145                         solve_expiration: expiration.into(),
    146                         earliest_retransmission: retransmission.into(),
    147                     })
    148                     .into_response()),
    149                 }
    150             },
    151         ),
    152     ).route(
    153         "/accounts/{username}/challenge/{id}/confirm",
    154         post(
    155             async |
    156                     State(state): State<Arc<BankState>>,
    157                    Path((_, id)): Path<((), Uuid)>,
    158                    Req(req): Req<ChallengeSolve>
    159                    | {
    160                 let code = req.tan.strip_prefix("T-").unwrap_or(&req.tan);
    161                 match solve(&state.db, &id, code, &Timestamp::now()).await? {
    162                     SolveResult::NotFound =>  Err(failure_code(ErrorCode::BANK_CHALLENGE_NOT_FOUND)),
    163                     SolveResult::BadCode => Err(failure_code(ErrorCode::BANK_TAN_CHALLENGE_FAILED)),
    164                     SolveResult::NoRetry => Err(failure_code(ErrorCode::BANK_TAN_RATE_LIMITED)),
    165                     SolveResult::Expired => Err(failure_code(ErrorCode::BANK_TAN_CHALLENGE_EXPIRED)),
    166                     SolveResult::Success { .. } => Ok(NoContent)
    167                 }
    168             },
    169         ),
    170     )
    171 }
    172 
    173 #[cfg(test)]
    174 pub mod test {
    175 
    176     use sqlx::postgres::PgConnectOptions;
    177     use taler_common::error_code::ErrorCode;
    178     use taler_macros::db_test;
    179     use taler_test_utils::{json, server::TestResponse};
    180     use uuid::Uuid;
    181 
    182     use crate::{
    183         TanChannel,
    184         api::{
    185             tan::{Challenge, ChallengeRequestResponse, ChallengeResponse, MAX_ACTIVE_CHALLENGES},
    186             test::{MfaRequest, bank_setup, bank_setup_conf, tan_code},
    187         },
    188         mfa::TALER_CHALLENGE_IDS,
    189     };
    190 
    191     #[db_test(raw)]
    192     async fn send(db: PgConnectOptions) {
    193         let ctx = bank_setup(db).await;
    194 
    195         let expect_mfa = async |res: TestResponse, and: bool, tans: &[(TanChannel, &str)]| {
    196             res.assert_challenge_check(&ctx, async |res| {
    197                 assert_eq!(
    198                     tans,
    199                     res.challenges
    200                         .iter()
    201                         .map(|it| (it.tan_channel, it.tan_info.as_str()))
    202                         .collect::<Vec<_>>()
    203                 );
    204                 assert_eq!(res.combi_and, and);
    205             })
    206             .await
    207         };
    208 
    209         let send = async |c: &Challenge| {
    210             ctx.posta(format!("/accounts/merchant/challenge/{}", c.challenge_id))
    211                 .await
    212                 .assert_ok();
    213         };
    214 
    215         macro_rules! patch {
    216             ($($json:tt)+) => {
    217                 ctx.patcha("/accounts/merchant").json(json!($($json)+)).await
    218             };
    219         }
    220 
    221         // Set up 2fa
    222         expect_mfa(
    223             patch!({
    224                 "contact_data": {
    225                     "phone": "+199",
    226                     "email": "email@example.com"
    227                 },
    228                 "tan_channel": "sms"
    229             }),
    230             true,
    231             &[(TanChannel::sms, "+199")],
    232         )
    233         .await
    234         .assert_no_content();
    235 
    236         // Update 2fa settings - first 2FA challenge then new tan channel check
    237         expect_mfa(
    238             patch!({ // Info change
    239                 "contact_data": { "phone": "+198" },
    240             }),
    241             true,
    242             &[(TanChannel::sms, "+199"), (TanChannel::sms, "+198")],
    243         )
    244         .await
    245         .assert_no_content();
    246         expect_mfa(
    247             patch!({ // Channel change
    248                 "tan_channel": "email"
    249             }),
    250             true,
    251             &[
    252                 (TanChannel::sms, "+198"),
    253                 (TanChannel::email, "email@example.com"),
    254             ],
    255         )
    256         .await
    257         .assert_no_content();
    258         expect_mfa(
    259             patch!({ // Both change
    260                 "contact_data": { "phone": "+197" },
    261                 "tan_channel": "sms"
    262             }),
    263             true,
    264             &[
    265                 (TanChannel::email, "email@example.com"),
    266                 (TanChannel::sms, "+197"),
    267             ],
    268         )
    269         .await
    270         .assert_no_content();
    271 
    272         // Disable 2fa
    273         expect_mfa(
    274             patch!({ // Both change
    275                 "tan_channel": ()
    276             }),
    277             true,
    278             &[(TanChannel::sms, "+197")],
    279         )
    280         .await
    281         .assert_no_content();
    282 
    283         // Update mfa settings - first mfa challenge then new tan channel check
    284         expect_mfa(
    285             patch!({ // Both change
    286                 "tan_channels": ["sms", "email"]
    287             }),
    288             true,
    289             &[
    290                 (TanChannel::sms, "+197"),
    291                 (TanChannel::email, "email@example.com"),
    292             ],
    293         )
    294         .await
    295         .assert_no_content();
    296         expect_mfa(
    297             expect_mfa(
    298                 patch!({
    299                     "contact_data": {
    300                         "phone": "+199",
    301                         "email": "email2@example.com"
    302                     }
    303                 }),
    304                 false,
    305                 &[
    306                     (TanChannel::sms, "+197"),
    307                     (TanChannel::email, "email@example.com"),
    308                 ],
    309             )
    310             .await,
    311             true,
    312             &[
    313                 (TanChannel::sms, "+199"),
    314                 (TanChannel::email, "email2@example.com"),
    315             ],
    316         )
    317         .await
    318         .assert_no_content();
    319 
    320         // Disable mfa
    321         expect_mfa(
    322             patch!({ "tan_channels": [] }),
    323             false,
    324             &[
    325                 (TanChannel::sms, "+199"),
    326                 (TanChannel::email, "email2@example.com"),
    327             ],
    328         )
    329         .await
    330         .assert_no_content();
    331 
    332         // Admin has no 2FA
    333         ctx.patch_admin("/accounts/merchant")
    334             .json(json!({
    335                 "contact_data": { "phone": "+199" },
    336                 "tan_channel": "sms"
    337             }))
    338             .await
    339             .assert_no_content();
    340         ctx.patch_admin("/accounts/merchant")
    341             .json(json!({
    342                 "tan_channel": "email"
    343             }))
    344             .await
    345             .assert_no_content();
    346         ctx.patch_admin("/accounts/merchant")
    347             .json(json!({
    348                 "tan_channel": ()
    349             }))
    350             .await
    351             .assert_no_content();
    352 
    353         // Check retry and invalidate
    354         {
    355             patch!({
    356                 "contact_data": { "phone": "+188" },
    357                 "tan_channel": "sms"
    358             })
    359             .assert_challenge(&ctx)
    360             .await
    361             .assert_no_content();
    362             let res: ChallengeResponse = ctx
    363                 .patcha("/accounts/merchant")
    364                 .json(json!({
    365                     "is_public": false
    366                 }))
    367                 .await
    368                 .assert_accepted_json();
    369             let challenge = &res.challenges[0];
    370             // Check ok
    371             send(challenge).await;
    372             let code = tan_code("+188").unwrap();
    373             // Check retry
    374             send(challenge).await;
    375             assert!(tan_code("+188").is_none());
    376             // Idempotent patch does nothing
    377             patch!({
    378                 "contact_data": { "phone": "+188" },
    379                 "tan_channel": "sms"
    380             })
    381             .assert_accepted();
    382             send(challenge).await;
    383             assert!(tan_code("+88").is_none());
    384 
    385             // Change 2fa settings
    386             patch!({
    387                 "tan_channel": "email"
    388             })
    389             .assert_challenge(&ctx)
    390             .await
    391             .assert_no_content();
    392 
    393             // Check invalidated
    394             ctx.posta(format!(
    395                 "/accounts/merchant/challenge/{}/confirm",
    396                 challenge.challenge_id,
    397             ))
    398             .json(json!({"tan": code}))
    399             .await
    400             .assert_error(ErrorCode::BANK_TAN_CHALLENGE_EXPIRED);
    401             ctx.patcha("/accounts/merchant")
    402                 .header(TALER_CHALLENGE_IDS, challenge.challenge_id.to_string())
    403                 .json(json!({"is_public": false}))
    404                 .await
    405                 .assert_challenge(&ctx)
    406                 .await
    407                 .assert_no_content();
    408         }
    409 
    410         // Unknown challenge
    411         ctx.posta(format!("/accounts/merchant/challenge/{}", Uuid::new_v4()))
    412             .await
    413             .assert_error(ErrorCode::BANK_CHALLENGE_NOT_FOUND);
    414 
    415         // Unknown challenge
    416         ctx.posta("/accounts/merchant/challenge/BAD")
    417             .await
    418             .assert_error(ErrorCode::GENERIC_PATH_SEGMENT_MALFORMED);
    419     }
    420 
    421     #[db_test(raw)]
    422     async fn rate_limited(db: PgConnectOptions) {
    423         let ctx = bank_setup(db).await;
    424         ctx.fill_tan_info("merchant").await;
    425 
    426         // TODO need transaction API
    427         let tx_challenge = async || {
    428             ctx.posta("/accounts/merchant/transactions")
    429                 .json(json!({
    430                     "payto_uri": format!("{}?message=tx&amount=KUDOS:0.1", ctx.customer_payto)
    431                 }))
    432                 .await
    433                 .assert_accepted_json::<ChallengeResponse>()
    434                 .challenges
    435                 .pop()
    436                 .unwrap()
    437         };
    438 
    439         let submit = async |c: &Challenge| {
    440             ctx.posta(format!("/accounts/merchant/challenge/{}", c.challenge_id))
    441                 .await
    442                 .assert_ok_json::<ChallengeRequestResponse>()
    443         };
    444 
    445         // Start a legitimate challenge and submit it
    446         let old = tx_challenge().await;
    447         submit(&old).await;
    448         let code = tan_code(&old.tan_info);
    449 
    450         // Challenge creation is not rate limited
    451         for _ in 0..MAX_ACTIVE_CHALLENGES * 2 {
    452             tx_challenge().await;
    453         }
    454 
    455         // Challenge submission is rate limited
    456         for _ in 0..MAX_ACTIVE_CHALLENGES - 1 {
    457             submit(&tx_challenge().await).await;
    458         }
    459         let c = tx_challenge().await;
    460         ctx.posta(format!("/accounts/merchant/challenge/{}", c.challenge_id))
    461             .await
    462             .assert_error(ErrorCode::BANK_TAN_RATE_LIMITED);
    463 
    464         // Old already submitted challenge still works
    465         submit(&old).await;
    466         ctx.posta(format!(
    467             "/accounts/merchant/challenge/{}/confirm",
    468             old.challenge_id
    469         ))
    470         .json(json!({ "tan": code }))
    471         .await
    472         .assert_no_content();
    473 
    474         // Now an active challenge slot have been freed
    475         submit(&c).await;
    476 
    477         // We are still rate limited
    478         let new = tx_challenge().await;
    479         ctx.posta(format!("/accounts/merchant/challenge/{}", new.challenge_id))
    480             .await
    481             .assert_error(ErrorCode::BANK_TAN_RATE_LIMITED);
    482     }
    483 
    484     #[db_test(raw)]
    485     async fn tan_err(db: PgConnectOptions) {
    486         let ctx = bank_setup_conf(db, "test_tan_err.conf").await;
    487         ctx.fill_tan_info("merchant").await;
    488         let res: ChallengeResponse = ctx
    489             .patcha("/accounts/merchant")
    490             .json(json!({
    491                 "is_public": false
    492             }))
    493             .await
    494             .assert_accepted_json();
    495         let challenge = &res.challenges[0];
    496         ctx.posta(format!(
    497             "/accounts/merchant/challenge/{}",
    498             challenge.challenge_id
    499         ))
    500         .await
    501         .assert_error(ErrorCode::BANK_TAN_CHANNEL_SCRIPT_FAILED);
    502     }
    503 
    504     #[db_test(raw)]
    505     async fn confirm(db: PgConnectOptions) {
    506         let ctx = bank_setup(db).await;
    507         ctx.fill_tan_info("merchant").await;
    508 
    509         // Check simple case
    510         {
    511             let res: ChallengeResponse = ctx
    512                 .patcha("/accounts/merchant")
    513                 .json(json!({ "is_public": false }))
    514                 .await
    515                 .assert_accepted_json();
    516             let challenge = &res.challenges[0];
    517             let id = &challenge.challenge_id;
    518             ctx.posta(format!("/accounts/merchant/challenge/{id}"))
    519                 .await
    520                 .assert_ok_json::<ChallengeRequestResponse>();
    521             let code = tan_code(&challenge.tan_info);
    522 
    523             // Check bad TAN code
    524             ctx.posta(format!("/accounts/merchant/challenge/{id}/confirm"))
    525                 .json(json!({ "tan": "nice-try" }))
    526                 .await
    527                 .assert_error(ErrorCode::BANK_TAN_CHALLENGE_FAILED);
    528 
    529             // Check wrong account
    530             ctx.posta(format!("/accounts/customer/challenge/{id}/confirm"))
    531                 .json(json!({ "tan": "nice-try" }))
    532                 .await
    533                 .assert_error(ErrorCode::BANK_TAN_CHALLENGE_FAILED);
    534 
    535             // Check OK
    536             ctx.posta(format!("/accounts/customer/challenge/{id}/confirm"))
    537                 .json(json!({ "tan": code }))
    538                 .await
    539                 .assert_no_content();
    540             // Check idempotence
    541             ctx.posta(format!("/accounts/customer/challenge/{id}/confirm"))
    542                 .json(json!({ "tan": code }))
    543                 .await
    544                 .assert_no_content();
    545 
    546             // Unknown challenge
    547             ctx.posta(format!(
    548                 "/accounts/customer/challenge/{}/confirm",
    549                 Uuid::new_v4()
    550             ))
    551             .json(json!({ "tan": code }))
    552             .await
    553             .assert_error(ErrorCode::BANK_CHALLENGE_NOT_FOUND);
    554         }
    555 
    556         // Check invalidation
    557         {
    558             let res: ChallengeResponse = ctx
    559                 .patcha("/accounts/merchant")
    560                 .json(json!({ "is_public": false }))
    561                 .await
    562                 .assert_accepted_json();
    563             let challenge = &res.challenges[0];
    564             let id = &challenge.challenge_id;
    565             ctx.posta(format!("/accounts/merchant/challenge/{id}"))
    566                 .await
    567                 .assert_ok_json::<ChallengeRequestResponse>();
    568 
    569             // Check invalidated
    570             ctx.fill_tan_info("merchant").await;
    571             ctx.posta(format!("/accounts/customer/challenge/{id}/confirm"))
    572                 .json(json!({ "tan": tan_code(&challenge.tan_info) }))
    573                 .await
    574                 .assert_error(ErrorCode::BANK_TAN_CHALLENGE_EXPIRED);
    575 
    576             ctx.posta(format!("/accounts/customer/challenge/{id}"))
    577                 .await
    578                 .assert_error(ErrorCode::BANK_TAN_CHALLENGE_EXPIRED);
    579         }
    580     }
    581 }